Full final production #6
1
.gitignore
vendored
1
.gitignore
vendored
@@ -1,2 +1,3 @@
|
|||||||
/node_modules
|
/node_modules
|
||||||
/traxer_api/.codeedit
|
/traxer_api/.codeedit
|
||||||
|
.DS_Store
|
||||||
|
|||||||
49
Dockerfile
Normal file
49
Dockerfile
Normal file
@@ -0,0 +1,49 @@
|
|||||||
|
# ---- Builder Stage ----
|
||||||
|
# Use official Node.js 20 (Alpine)
|
||||||
|
FROM node:20-alpine AS builder
|
||||||
|
|
||||||
|
# Install init system
|
||||||
|
RUN apk add --no-cache dumb-init
|
||||||
|
|
||||||
|
# App dir
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Copy package files
|
||||||
|
COPY package*.json ./
|
||||||
|
|
||||||
|
# Install production deps
|
||||||
|
RUN npm ci --only=production && npm cache clean --force
|
||||||
|
|
||||||
|
# ---- Final Stage ----
|
||||||
|
FROM node:20-alpine
|
||||||
|
|
||||||
|
LABEL maintainer="your-email@example.com"
|
||||||
|
LABEL org.opencontainers.image.source="https://github.com/yourname/uta-gtfs-mysql"
|
||||||
|
|
||||||
|
# Install dumb-init + MySQL client (optional but common)
|
||||||
|
RUN apk add --no-cache dumb-init mysql-client
|
||||||
|
|
||||||
|
# Create non-root user
|
||||||
|
RUN addgroup -g 1001 -S nodejs \
|
||||||
|
&& adduser -S utauser -u 1001
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
# Copy node_modules from builder
|
||||||
|
COPY --from=builder /app/node_modules ./node_modules
|
||||||
|
|
||||||
|
# Copy app source
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
RUN chown -R utauser:nodejs /app
|
||||||
|
USER utauser
|
||||||
|
|
||||||
|
# Healthcheck – you may want to change port if needed
|
||||||
|
HEALTHCHECK --interval=30s --timeout=3s --start-period=60s --retries=3 \
|
||||||
|
CMD wget -qO- http://localhost:3000/ || exit 1
|
||||||
|
|
||||||
|
EXPOSE 3000
|
||||||
|
|
||||||
|
ENTRYPOINT ["dumb-init", "--"]
|
||||||
|
|
||||||
|
CMD ["node", "server.js"]
|
||||||
4059
alerts.json
4059
alerts.json
File diff suppressed because it is too large
Load Diff
22
app.js
22
app.js
@@ -1,22 +0,0 @@
|
|||||||
import express from "express";
|
|
||||||
import cors from "cors";
|
|
||||||
import vehiclesRouter from "./src/routes/vehicles.js";
|
|
||||||
import routesRouter from "./src/routes/routes.js";
|
|
||||||
|
|
||||||
const app = express();
|
|
||||||
app.use(cors());
|
|
||||||
app.use(express.json());
|
|
||||||
|
|
||||||
const base = "/api/v0";
|
|
||||||
|
|
||||||
app.get(`${base}/`, (req, res) => {
|
|
||||||
res.json({status: "ok", version: "v0", ts: new Date().toISOString()});
|
|
||||||
});
|
|
||||||
|
|
||||||
app.use(`${base}/vehicles`, vehiclesRouter);
|
|
||||||
|
|
||||||
app.use(base, routesRouter);
|
|
||||||
|
|
||||||
app.get("/", (req, res) => res.redirect(`${base}/`));
|
|
||||||
|
|
||||||
export default app;
|
|
||||||
45
docker-compose.yaml
Normal file
45
docker-compose.yaml
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
version: '3.9'
|
||||||
|
|
||||||
|
services:
|
||||||
|
uta-mysql:
|
||||||
|
image: mysql:8.0
|
||||||
|
container_name: uta-mysql
|
||||||
|
restart: unless-stopped
|
||||||
|
environment:
|
||||||
|
MYSQL_ROOT_PASSWORD: uta123
|
||||||
|
MYSQL_DATABASE: uta
|
||||||
|
MYSQL_USER: uta
|
||||||
|
MYSQL_PASSWORD: uta123
|
||||||
|
command: --default-authentication-plugin=mysql_native_password
|
||||||
|
volumes:
|
||||||
|
- mysql_data:/var/lib/mysql
|
||||||
|
- ./init-db.sql:/docker-entrypoint-initdb.d/init-db.sql:ro
|
||||||
|
ports:
|
||||||
|
- "3306:3306"
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "mysqladmin", "ping", "-h", "localhost", "-puta", "--password=uta123"]
|
||||||
|
interval: 10s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 5
|
||||||
|
|
||||||
|
uta-sync:
|
||||||
|
build: .
|
||||||
|
container_name: uta-sync
|
||||||
|
ports:
|
||||||
|
- "1001:1001"
|
||||||
|
restart: unless-stopped
|
||||||
|
environment:
|
||||||
|
- DATABASE_URL=mysql://uta:uta123@uta-mysql:3306/uta
|
||||||
|
- HEALTH_PORT=3000
|
||||||
|
- NODE_ENV=production
|
||||||
|
depends_on:
|
||||||
|
uta-mysql:
|
||||||
|
condition: service_healthy
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "wget", "--spider", "http://localhost:3000/health"]
|
||||||
|
interval: 30s
|
||||||
|
timeout: 15s
|
||||||
|
retries: 3
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
mysql_data:
|
||||||
98
init-db.sql
Normal file
98
init-db.sql
Normal file
@@ -0,0 +1,98 @@
|
|||||||
|
CREATE TABLE routes (
|
||||||
|
route_id VARCHAR(50) PRIMARY KEY,
|
||||||
|
agency_id VARCHAR(50),
|
||||||
|
route_short_name VARCHAR(50),
|
||||||
|
route_long_name VARCHAR(255),
|
||||||
|
route_desc TEXT,
|
||||||
|
route_type INT,
|
||||||
|
route_url VARCHAR(255),
|
||||||
|
route_color VARCHAR(10),
|
||||||
|
route_text_color VARCHAR(10)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE trips (
|
||||||
|
trip_id VARCHAR(50) PRIMARY KEY,
|
||||||
|
route_id VARCHAR(50),
|
||||||
|
service_id VARCHAR(50),
|
||||||
|
shape_id VARCHAR(50),
|
||||||
|
trip_headsign VARCHAR(255),
|
||||||
|
trip_short_name VARCHAR(50),
|
||||||
|
direction_id INT,
|
||||||
|
block_id VARCHAR(50),
|
||||||
|
wheelchair_accessible INT,
|
||||||
|
bikes_allowed INT,
|
||||||
|
FOREIGN KEY (route_id) REFERENCES routes(route_id)
|
||||||
|
);
|
||||||
|
CREATE TABLE stops (
|
||||||
|
stop_id VARCHAR(50) PRIMARY KEY,
|
||||||
|
stop_code VARCHAR(50),
|
||||||
|
stop_name VARCHAR(255),
|
||||||
|
stop_desc TEXT,
|
||||||
|
stop_lat DOUBLE,
|
||||||
|
stop_lon DOUBLE,
|
||||||
|
zone_id VARCHAR(50),
|
||||||
|
stop_url VARCHAR(255),
|
||||||
|
location_type INT DEFAULT NULL,
|
||||||
|
parent_station VARCHAR(50),
|
||||||
|
stop_timezone VARCHAR(50),
|
||||||
|
wheelchair_boarding INT DEFAULT NULL
|
||||||
|
);
|
||||||
|
CREATE TABLE stop_times (
|
||||||
|
trip_id VARCHAR(50),
|
||||||
|
arrival_time VARCHAR(20),
|
||||||
|
departure_time VARCHAR(20),
|
||||||
|
stop_id VARCHAR(50),
|
||||||
|
stop_sequence INT,
|
||||||
|
stop_headsign VARCHAR(255),
|
||||||
|
pickup_type INT,
|
||||||
|
drop_off_type INT,
|
||||||
|
shape_dist_traveled DOUBLE NOT NULL DEFAULT 0
|
||||||
|
timepoint INT,
|
||||||
|
PRIMARY KEY (trip_id, stop_id, stop_sequence),
|
||||||
|
FOREIGN KEY (trip_id) REFERENCES trips(trip_id),
|
||||||
|
FOREIGN KEY (stop_id) REFERENCES stops(stop_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE TABLE shapes (
|
||||||
|
shape_id VARCHAR(50),
|
||||||
|
shape_pt_lat DOUBLE,
|
||||||
|
shape_pt_lon DOUBLE,
|
||||||
|
shape_pt_sequence INT,
|
||||||
|
shape_dist_traveled DOUBLE,
|
||||||
|
PRIMARY KEY (shape_id, shape_pt_sequence)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Realtime: vehicle positions
|
||||||
|
CREATE TABLE rt_vehicle_positions (
|
||||||
|
vehicle_id VARCHAR(50),
|
||||||
|
trip_id VARCHAR(50),
|
||||||
|
route_id VARCHAR(50),
|
||||||
|
lat DOUBLE,
|
||||||
|
lon DOUBLE,
|
||||||
|
bearing INT,
|
||||||
|
speed DOUBLE,
|
||||||
|
timestamp BIGINT,
|
||||||
|
PRIMARY KEY (vehicle_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Realtime: trip updates
|
||||||
|
CREATE TABLE rt_trip_updates (
|
||||||
|
trip_id VARCHAR(50),
|
||||||
|
stop_id VARCHAR(50),
|
||||||
|
arrival_time BIGINT,
|
||||||
|
departure_time BIGINT,
|
||||||
|
delay INT,
|
||||||
|
schedule_relationship VARCHAR(20),
|
||||||
|
PRIMARY KEY (trip_id, stop_id),
|
||||||
|
FOREIGN KEY (stop_id) REFERENCES stops(stop_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
-- Realtime: alerts
|
||||||
|
CREATE TABLE rt_alerts (
|
||||||
|
alert_id VARCHAR(50) PRIMARY KEY,
|
||||||
|
header TEXT,
|
||||||
|
description TEXT,
|
||||||
|
cause VARCHAR(50),
|
||||||
|
effect VARCHAR(50),
|
||||||
|
timestamp BIGINT
|
||||||
|
);
|
||||||
1484
package-lock.json
generated
1484
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
22
package.json
22
package.json
@@ -1,22 +1,24 @@
|
|||||||
{
|
{
|
||||||
"name": "project",
|
"name": "traxer2",
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"description": "",
|
"description": "",
|
||||||
"main": "app.js",
|
"main": "index.js",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"test": "echo \"Error: no test specified\" && exit 1",
|
"test": "echo \"Error: no test specified\" && exit 1"
|
||||||
"start": "node start.js"
|
|
||||||
},
|
|
||||||
"repository": {
|
|
||||||
"type": "git",
|
|
||||||
"url": "https://git.nathanspackman.com/PRO150-Group/Project"
|
|
||||||
},
|
},
|
||||||
"keywords": [],
|
"keywords": [],
|
||||||
"author": "",
|
"author": "",
|
||||||
"license": "ISC",
|
"license": "ISC",
|
||||||
"type": "module",
|
"type": "commonjs",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"cors": "^2.8.5",
|
"cors": "^2.8.5",
|
||||||
"express": "^5.1.0"
|
"csv-parse": "^6.1.0",
|
||||||
|
"express": "^5.2.1",
|
||||||
|
"gtfs-realtime-bindings": "^1.1.1",
|
||||||
|
"mysql2": "^3.15.3",
|
||||||
|
"node-fetch": "^3.3.2",
|
||||||
|
"parse": "^7.1.2",
|
||||||
|
"pg": "^8.16.3",
|
||||||
|
"unzipper": "^0.12.3"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,24 +1,39 @@
|
|||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<html lang="en">
|
<html lang="en">
|
||||||
<head>
|
|
||||||
<meta charset="UTF-8">
|
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
||||||
<title>Traxer</title>
|
|
||||||
<script src="https://cdn.tailwindcss.com"></script>
|
|
||||||
<link
|
|
||||||
rel="stylesheet"
|
|
||||||
href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css"
|
|
||||||
crossorigin=""
|
|
||||||
/>
|
|
||||||
<script
|
|
||||||
src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"
|
|
||||||
crossorigin=""
|
|
||||||
></script>
|
|
||||||
</head>
|
|
||||||
<body>
|
|
||||||
<div id="map" class="w-full h-screen"></div>
|
|
||||||
|
|
||||||
</body>
|
|
||||||
<script src="app.js"></script>
|
|
||||||
|
|
||||||
</html>
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>Traxer</title>
|
||||||
|
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" />
|
||||||
|
<link rel="stylesheet" href="style.css" />
|
||||||
|
</head>
|
||||||
|
|
||||||
|
<body>
|
||||||
|
|
||||||
|
<div id="map"></div>
|
||||||
|
<div
|
||||||
|
style="position:absolute; top:10px; right:10px; z-index:1000; background:white; padding:8px; border-radius:4px; box-shadow:0 0 10px rgba(0,0,0,0.3);">
|
||||||
|
<button id="toggleStops">Hide Stops</button>
|
||||||
|
</div>
|
||||||
|
<div class="controls">
|
||||||
|
<select id="routeSelect">
|
||||||
|
<option value="">All Trains & Buses</option>
|
||||||
|
<option value="701">Blue Line (701)</option>
|
||||||
|
<option value="703">Red Line (703)</option>
|
||||||
|
<option value="704">Green Line (704)</option>
|
||||||
|
<option value="720">S-Line (720)</option>
|
||||||
|
<option value="750">FrontRunner (750)</option>
|
||||||
|
</select>
|
||||||
|
<button id="refresh">Refresh Now</button>
|
||||||
|
<span class="status" id="status">Loading...</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="vehicleSchedule" style="margin-top: 20px;">
|
||||||
|
</div>
|
||||||
|
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
|
||||||
|
<script src="index.js"> </script>
|
||||||
|
|
||||||
|
</body>
|
||||||
|
|
||||||
|
</html>
|
||||||
388
public/index.js
Normal file
388
public/index.js
Normal file
@@ -0,0 +1,388 @@
|
|||||||
|
const map = L.map("map").setView([40.7608, -111.8910], 12);
|
||||||
|
|
||||||
|
L.tileLayer("https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png", {
|
||||||
|
attribution: '© OpenStreetMap contributors'
|
||||||
|
}).addTo(map);
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
const trainIcon = L.divIcon({
|
||||||
|
html: '🚂',
|
||||||
|
className: "train-icon",
|
||||||
|
iconSize: [30, 30],
|
||||||
|
iconAnchor: [15, 15]
|
||||||
|
});
|
||||||
|
|
||||||
|
const busIcon = L.divIcon({
|
||||||
|
html: '🚌',
|
||||||
|
className: "train-icon",
|
||||||
|
iconSize: [28, 28],
|
||||||
|
iconAnchor: [14, 14]
|
||||||
|
});
|
||||||
|
|
||||||
|
const vehicleMarkers = L.layerGroup().addTo(map);
|
||||||
|
const routeShapes = L.layerGroup().addTo(map);
|
||||||
|
const stopMarkers = L.layerGroup().addTo(map);
|
||||||
|
|
||||||
|
|
||||||
|
const API = "http://localhost:1001/api";
|
||||||
|
const vehicleMarkersMap = {};
|
||||||
|
|
||||||
|
let stopsVisible = true;
|
||||||
|
let currentRoute = "";
|
||||||
|
|
||||||
|
async function loadStops(routeId = null) {
|
||||||
|
stopMarkers.clearLayers();
|
||||||
|
if (!stopsVisible) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
let url = `${API}/stop`;
|
||||||
|
if (routeId) url = `${API}/stop/${routeId}`;
|
||||||
|
|
||||||
|
const res = await fetch(url);
|
||||||
|
const stops = await res.json();
|
||||||
|
|
||||||
|
stops.forEach(stop => {
|
||||||
|
if (!stop.stop_lat || !stop.stop_lon) return;
|
||||||
|
|
||||||
|
const wheelchair = (stop.wheelchair_boarding === "1" || stop.wheelchair_boarding === 1) ? " ♿" : "";
|
||||||
|
|
||||||
|
const marker = L.circleMarker([stop.stop_lat, stop.stop_lon], {
|
||||||
|
radius: 6,
|
||||||
|
color: "#0077ff",
|
||||||
|
fillColor: "#0077ff",
|
||||||
|
fillOpacity: 0.8
|
||||||
|
})
|
||||||
|
.bindPopup(`<b>${stop.stop_name}${wheelchair}</b><br>ID: ${stop.stop_desc || stop.stop_id}`)
|
||||||
|
.addTo(stopMarkers);
|
||||||
|
|
||||||
|
marker.on('click', () => showStopSchedule(stop.stop_id, stop.stop_name));
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Error loading stops:", err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
document.getElementById("toggleStops").addEventListener("click", () => {
|
||||||
|
stopsVisible = !stopsVisible;
|
||||||
|
if (stopsVisible) {
|
||||||
|
document.getElementById("toggleStops").textContent = "Hide Stops";
|
||||||
|
// Reload current stops (all or just the selected route)
|
||||||
|
if (currentRoute === "") {
|
||||||
|
loadStops();
|
||||||
|
} else {
|
||||||
|
loadStops(currentRoute);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
document.getElementById("toggleStops").textContent = "Show Stops";
|
||||||
|
stopMarkers.clearLayers();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
async function showStopSchedule(stopId, stopName) {
|
||||||
|
const scheduleDiv = document.getElementById("vehicleSchedule");
|
||||||
|
scheduleDiv.innerHTML = "Loading stop schedule...";
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${API}/schedule/?station=${stopId}`);
|
||||||
|
const data = await res.json();
|
||||||
|
|
||||||
|
if (!data || data.length === 0) {
|
||||||
|
scheduleDiv.innerHTML = `<p>No upcoming arrivals for <strong>${stopName}</strong></p>`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const now = new Date();
|
||||||
|
const today = new Date(now.getFullYear(), now.getMonth(), now.getDate()); // midnight today
|
||||||
|
|
||||||
|
// Process and filter valid arrivals
|
||||||
|
const upcoming = data
|
||||||
|
.map(item => {
|
||||||
|
const arrivalRaw = item.stopTime?.arrival_time?.trim();
|
||||||
|
if (!arrivalRaw || !item.trip || !item.route) return null; // skip invalid
|
||||||
|
|
||||||
|
let [hours, minutes, seconds] = arrivalRaw.split(":").map(Number);
|
||||||
|
let arrivalDate = new Date(today);
|
||||||
|
if (hours >= 24) {
|
||||||
|
hours -= 24;
|
||||||
|
arrivalDate.setDate(arrivalDate.getDate() + 1);
|
||||||
|
}
|
||||||
|
arrivalDate.setHours(hours, minutes, seconds, 0);
|
||||||
|
|
||||||
|
return { ...item, arrivalDate };
|
||||||
|
})
|
||||||
|
.filter(item => item && item.arrivalDate > now) // remove past or invalid
|
||||||
|
.sort((a, b) => a.arrivalDate - b.arrivalDate);
|
||||||
|
|
||||||
|
// Deduplicate by trip_id + arrival_time
|
||||||
|
const seen = new Set();
|
||||||
|
const uniqueUpcoming = upcoming.filter(item => {
|
||||||
|
const key = `${item.trip.trip_id}_${item.arrivalDate.getTime()}`;
|
||||||
|
if (seen.has(key)) return false;
|
||||||
|
seen.add(key);
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
|
||||||
|
if (uniqueUpcoming.length === 0) {
|
||||||
|
scheduleDiv.innerHTML = `<p>No upcoming arrivals for <strong>${stopName}</strong></p>`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build table
|
||||||
|
let html = `<h3>Upcoming at ${stopName}</h3>`;
|
||||||
|
html += `<table style="width:100%; border-collapse:collapse; font-size:0.95em;">
|
||||||
|
<thead style="background:#333; color:white;">
|
||||||
|
<tr>
|
||||||
|
<th style="padding:8px;">Route</th>
|
||||||
|
<th style="padding:8px;">Destination</th>
|
||||||
|
<th style="padding:8px;">Arrival</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>`;
|
||||||
|
|
||||||
|
uniqueUpcoming.forEach(item => {
|
||||||
|
const { stopTime, trip, route, arrivalDate } = item;
|
||||||
|
const routeNum = route?.route_short_name || route?.route_id || "—";
|
||||||
|
const headsign = trip?.trip_headsign || "—";
|
||||||
|
|
||||||
|
const minutesUntil = Math.round((arrivalDate - now) / 60000);
|
||||||
|
const displayTime = arrivalDate.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
|
||||||
|
|
||||||
|
const timeDisplay = minutesUntil <= 30
|
||||||
|
? `<strong style="color:#d00;">${displayTime} (${minutesUntil === 0 ? "Due" : minutesUntil + " min"})</strong>`
|
||||||
|
: displayTime;
|
||||||
|
|
||||||
|
const rowColor = route?.route_color && route.route_color !== "000000"
|
||||||
|
? `#${route.route_color}`
|
||||||
|
: "#666";
|
||||||
|
|
||||||
|
html += `<tr style="border-bottom:1px solid #ddd;">
|
||||||
|
<td style="padding:6px; text-align:center; background:${rowColor}; color:${getContrastColor(rowColor)}; font-weight:bold;">
|
||||||
|
${routeNum}
|
||||||
|
</td>
|
||||||
|
<td style="padding:6px;">${headsign}</td>
|
||||||
|
<td style="padding:6px; text-align:center;">${timeDisplay}</td>
|
||||||
|
</tr>`;
|
||||||
|
});
|
||||||
|
|
||||||
|
html += `</tbody></table>`;
|
||||||
|
html += `<small style="color:#666; display:block; margin-top:8px;">
|
||||||
|
Updated ${now.toLocaleTimeString()}
|
||||||
|
</small>`;
|
||||||
|
|
||||||
|
scheduleDiv.innerHTML = html;
|
||||||
|
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Error fetching stop schedule:", err);
|
||||||
|
scheduleDiv.innerHTML = "<p>Error loading schedule.</p>";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
function getContrastColor(hex) {
|
||||||
|
if (!hex || hex.length < 6) return "#ffffff";
|
||||||
|
const r = parseInt(hex.substr(0, 2), 16);
|
||||||
|
const g = parseInt(hex.substr(2, 2), 16);
|
||||||
|
const b = parseInt(hex.substr(4, 2), 16);
|
||||||
|
const luminance = (0.299 * r + 0.587 * g + 0.114 * b) / 255;
|
||||||
|
return luminance > 0.5 ? "#000000" : "#ffffff";
|
||||||
|
}
|
||||||
|
|
||||||
|
async function updateVehicles() {
|
||||||
|
document.getElementById("status").textContent = "Updating...";
|
||||||
|
|
||||||
|
try {
|
||||||
|
let trains = [];
|
||||||
|
let buses = [];
|
||||||
|
|
||||||
|
if (!currentRoute || ["701", "703", "704", "720", "750"].includes(currentRoute)) {
|
||||||
|
const trainRes = currentRoute
|
||||||
|
? await fetch(`${API}/train/${currentRoute}`)
|
||||||
|
: await fetch(`${API}/train`);
|
||||||
|
trains = await trainRes.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!currentRoute) {
|
||||||
|
const busRes = await fetch(`${API}/bus`);
|
||||||
|
buses = await busRes.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
const allVehicles = [...trains, ...buses];
|
||||||
|
const seenVehicleIds = new Set();
|
||||||
|
|
||||||
|
allVehicles.forEach(item => {
|
||||||
|
const v = item.vehicle;
|
||||||
|
if (!v?.position?.latitude) return;
|
||||||
|
|
||||||
|
const id = v.vehicle.id;
|
||||||
|
seenVehicleIds.add(id);
|
||||||
|
|
||||||
|
const lat = v.position.latitude;
|
||||||
|
const lon = v.position.longitude;
|
||||||
|
const route = item.route?.route_short_name || "???";
|
||||||
|
const isTrain = trains.includes(item);
|
||||||
|
const icon = isTrain ? trainIcon : busIcon;
|
||||||
|
|
||||||
|
const speed = v.position.speed ? (v.position.speed * 2.237).toFixed(0) + " mph" : "Stopped";
|
||||||
|
const popup = `
|
||||||
|
<b>${item.route?.route_long_name || route + " Service"}</b><br/>
|
||||||
|
Vehicle ID: ${id}<br/>
|
||||||
|
Route: ${route}<br/>
|
||||||
|
Speed: ${speed}<br/>
|
||||||
|
Updated: ${new Date(v.timestamp * 1000).toLocaleTimeString()}
|
||||||
|
`;
|
||||||
|
|
||||||
|
if (vehicleMarkersMap[id]) {
|
||||||
|
vehicleMarkersMap[id].setLatLng([lat, lon]);
|
||||||
|
vehicleMarkersMap[id].setPopupContent(popup);
|
||||||
|
} else {
|
||||||
|
const marker = L.marker([lat, lon], {
|
||||||
|
icon: icon,
|
||||||
|
rotationAngle: v.position.bearing || 0
|
||||||
|
}).bindPopup(popup)
|
||||||
|
.addTo(vehicleMarkers);
|
||||||
|
vehicleMarkersMap[id] = marker;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
Object.keys(vehicleMarkersMap).forEach(id => {
|
||||||
|
if (!seenVehicleIds.has(id)) {
|
||||||
|
vehicleMarkers.removeLayer(vehicleMarkersMap[id]);
|
||||||
|
delete vehicleMarkersMap[id];
|
||||||
|
}
|
||||||
|
});
|
||||||
|
document.getElementById("status").textContent = `${allVehicles.length} vehicles • ${new Date().toLocaleTimeString()}`;
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Update failed:", err);
|
||||||
|
document.getElementById("status").textContent = "Error";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
async function loadRouteShape(routeId) {
|
||||||
|
if (!routeId) {
|
||||||
|
routeShapes.clearLayers();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${API}/shape/${routeId}`);
|
||||||
|
const shapes = await res.json();
|
||||||
|
routeShapes.clearLayers();
|
||||||
|
const res2 = await fetch(`${API}/routes`);
|
||||||
|
const routes = await res2.json();
|
||||||
|
const route = routes.filter(r => r.route_short_name == routeId);
|
||||||
|
console.log(route[0])
|
||||||
|
const routeHexColor = `#${route[0].route_color}`;
|
||||||
|
|
||||||
|
shapes.forEach(shape => {
|
||||||
|
const points = shape.points.map(p => [p.lat, p.lon]);
|
||||||
|
L.polyline(points, { color: routeHexColor, weight: 5, opacity: 0.7 }).addTo(routeShapes);
|
||||||
|
});
|
||||||
|
if (shapes.length > 0) {
|
||||||
|
const bounds = L.latLngBounds(shapes[0].points.map(p => [p.lat, p.lon]));
|
||||||
|
map.fitBounds(bounds, { padding: [50, 50] });
|
||||||
|
}
|
||||||
|
} catch (e) { console.error("Shape load error:", e); }
|
||||||
|
}
|
||||||
|
|
||||||
|
document.getElementById("routeSelect").addEventListener("change", async (e) => {
|
||||||
|
currentRoute = e.target.value;
|
||||||
|
console.log(currentRoute == "")
|
||||||
|
if (currentRoute === "") {
|
||||||
|
loadAllRoutes();
|
||||||
|
loadStops();
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
loadRouteShape(currentRoute);
|
||||||
|
loadStops(currentRoute);
|
||||||
|
}
|
||||||
|
updateVehicles();
|
||||||
|
|
||||||
|
});
|
||||||
|
|
||||||
|
function delay(ms) {
|
||||||
|
return new Promise(resolve => setTimeout(resolve, ms));
|
||||||
|
}
|
||||||
|
|
||||||
|
document.getElementById("refresh").addEventListener("click", updateVehicles);
|
||||||
|
|
||||||
|
async function loadAllRoutes() {
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${API}/routes`);
|
||||||
|
const routes = await res.json();
|
||||||
|
|
||||||
|
routeShapes.clearLayers();
|
||||||
|
|
||||||
|
for (const route of routes) {
|
||||||
|
const routeId = route.route_short_name;
|
||||||
|
const routeHexColor = route.route_color ? `#${route.route_color}` : "#888";
|
||||||
|
|
||||||
|
const shapeRes = await fetch(`${API}/shape/${routeId}`);
|
||||||
|
const shapes = await shapeRes.json();
|
||||||
|
|
||||||
|
shapes.forEach(shape => {
|
||||||
|
const points = shape.points.map(p => [p.lat, p.lon]);
|
||||||
|
L.polyline(points, {
|
||||||
|
color: routeHexColor,
|
||||||
|
weight: 4,
|
||||||
|
opacity: 0.6
|
||||||
|
}).addTo(routeShapes);
|
||||||
|
});
|
||||||
|
//await delay(200);
|
||||||
|
}
|
||||||
|
|
||||||
|
const allPoints = [];
|
||||||
|
routeShapes.eachLayer(layer => {
|
||||||
|
allPoints.push(...layer.getLatLngs());
|
||||||
|
});
|
||||||
|
} catch (e) {
|
||||||
|
console.error("Error loading all routes:", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function showVehicleSchedule(vehicleId) {
|
||||||
|
const scheduleDiv = document.getElementById("vehicleSchedule");
|
||||||
|
scheduleDiv.innerHTML = "Loading schedule...";
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${API}/schedule/?vehicle=${vehicleId}`);
|
||||||
|
const schedule = await res.json();
|
||||||
|
|
||||||
|
if (!schedule.length) {
|
||||||
|
scheduleDiv.innerHTML = "<p>No schedule available.</p>";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let html = `
|
||||||
|
<h3>Schedule for Vehicle ${vehicleId}</h3>
|
||||||
|
<table border="1" cellpadding="5" cellspacing="0">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Stop</th>
|
||||||
|
<th>Arrival Time</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
`;
|
||||||
|
|
||||||
|
schedule.forEach(stop => {
|
||||||
|
const time = new Date(stop.arrival_time * 1000).toLocaleTimeString();
|
||||||
|
html += `<tr><td>${stop.stop_name}</td><td>${time}</td></tr>`;
|
||||||
|
});
|
||||||
|
|
||||||
|
html += `</tbody></table>`;
|
||||||
|
scheduleDiv.innerHTML = html;
|
||||||
|
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Error fetching schedule:", err);
|
||||||
|
scheduleDiv.innerHTML = "<p>Error loading schedule.</p>";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
loadAllRoutes()
|
||||||
|
.then(() => loadStops())
|
||||||
|
.then(() => {
|
||||||
|
updateVehicles();
|
||||||
|
setInterval(updateVehicles, 10000); // Update every 10 seconds
|
||||||
|
})
|
||||||
|
.catch(err => {
|
||||||
|
console.error("Init failed:", err);
|
||||||
|
document.getElementById("status").textContent = "Failed to initialize";
|
||||||
|
});
|
||||||
63
public/style.css
Normal file
63
public/style.css
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
font-family: system-ui, sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
#map {
|
||||||
|
width: 100vw;
|
||||||
|
height: 80vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
.controls {
|
||||||
|
position: absolute;
|
||||||
|
top: 10px;
|
||||||
|
left: 50%;
|
||||||
|
transform: translateX(-50%);
|
||||||
|
background: rgba(255, 255, 255, 0.95);
|
||||||
|
padding: 12px 20px;
|
||||||
|
border-radius: 12px;
|
||||||
|
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.2);
|
||||||
|
z-index: 1000;
|
||||||
|
text-align: center;
|
||||||
|
display: flex;
|
||||||
|
gap: 12px;
|
||||||
|
align-items: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
select,
|
||||||
|
button {
|
||||||
|
padding: 10px;
|
||||||
|
font-size: 15px;
|
||||||
|
border-radius: 8px;
|
||||||
|
border: 1px solid #ccc;
|
||||||
|
}
|
||||||
|
|
||||||
|
button {
|
||||||
|
background: #0066cc;
|
||||||
|
color: white;
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
button:hover {
|
||||||
|
background: #0052a3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.status {
|
||||||
|
font-weight: bold;
|
||||||
|
color: #0066cc;
|
||||||
|
}
|
||||||
|
|
||||||
|
.legend {
|
||||||
|
position: absolute;
|
||||||
|
top: 10px;
|
||||||
|
right: 10px;
|
||||||
|
background: white;
|
||||||
|
padding: 12px;
|
||||||
|
border-radius: 8px;
|
||||||
|
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.15);
|
||||||
|
z-index: 1000;
|
||||||
|
font-size: 13px;
|
||||||
|
}
|
||||||
93
server.js
Normal file
93
server.js
Normal file
@@ -0,0 +1,93 @@
|
|||||||
|
const express = require('express')
|
||||||
|
const path = require('path');
|
||||||
|
const app = express()
|
||||||
|
const gtfsRealtime = require("./tools/gtfsData")
|
||||||
|
app.use(express.static('public'))
|
||||||
|
app.use((req, res, next) => {
|
||||||
|
res.header('Access-Control-Allow-Origin', '*'); // or 'http://127.0.0.1:1001' for security
|
||||||
|
res.header('Access-Control-Allow-Headers', 'Origin, X-Requested-With, Content-Type, Accept');
|
||||||
|
next();
|
||||||
|
});
|
||||||
|
const port = 1001
|
||||||
|
|
||||||
|
app.get('/', (req, res) => {
|
||||||
|
res.sendFile(path.join(__dirname, 'public', 'index.html'));
|
||||||
|
})
|
||||||
|
|
||||||
|
app.get('/api/', (req, res) => {
|
||||||
|
res.json({ version: "0.0.1", status: "healthy" })
|
||||||
|
})
|
||||||
|
|
||||||
|
app.get('/api/train/', async (req, res) => {
|
||||||
|
res.json(await gtfsRealtime.getTrains())
|
||||||
|
})
|
||||||
|
|
||||||
|
app.get('/api/train/:routeId', async (req, res) => {
|
||||||
|
const routeId = req.params.routeId;
|
||||||
|
res.json(await gtfsRealtime.getTrainsByRoute(routeId))
|
||||||
|
})
|
||||||
|
|
||||||
|
app.get('/api/bus/', async (req, res) => {
|
||||||
|
res.json(await gtfsRealtime.getBuses())
|
||||||
|
})
|
||||||
|
|
||||||
|
app.get('/api/bus/:routeId', async (req, res) => {
|
||||||
|
const routeId = req.params.routeId;
|
||||||
|
res.json(await gtfsRealtime.getBusesByRoute(routeId))
|
||||||
|
})
|
||||||
|
|
||||||
|
app.get('/api/alert', async (req, res) => {
|
||||||
|
res.json(await gtfsRealtime.getAlerts())
|
||||||
|
})
|
||||||
|
|
||||||
|
app.get('/api/alert/:routeId', async (req, res) => {
|
||||||
|
const routeId = req.params.routeId;
|
||||||
|
res.json(await gtfsRealtime.getAlertsByRoute(routeId))
|
||||||
|
})
|
||||||
|
|
||||||
|
app.get('/api/schedule', async (req, res) => {
|
||||||
|
const { route, station } = req.query;
|
||||||
|
try {
|
||||||
|
let result;
|
||||||
|
if (route) {
|
||||||
|
result = await gtfsRealtime.getScheduleByRoute(route);
|
||||||
|
} else if (station) {
|
||||||
|
result = await gtfsRealtime.getScheduleByStationId(station);
|
||||||
|
} else {
|
||||||
|
return res.status(400).json({ error: "Please provide route, or station query parameter" });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!result || (Array.isArray(result) && result.length === 0)) {
|
||||||
|
return res.status(404).json({ error: "No schedule found" });
|
||||||
|
}
|
||||||
|
|
||||||
|
res.json(result);
|
||||||
|
} catch (err) {
|
||||||
|
console.error(err);
|
||||||
|
|
||||||
|
res.status(500).json({ error: "Internal server error" });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
app.get('/api/routes', async (req, res) => {
|
||||||
|
res.json(await gtfsRealtime.getRoutes())
|
||||||
|
})
|
||||||
|
|
||||||
|
app.get('/api/shape/:routeId', async (req, res) => {
|
||||||
|
const routeId = req.params.routeId;
|
||||||
|
res.json(await gtfsRealtime.getShapeByRoute(routeId))
|
||||||
|
})
|
||||||
|
|
||||||
|
app.get('/api/stop', async (req, res) => {
|
||||||
|
res.json(await gtfsRealtime.getStops())
|
||||||
|
})
|
||||||
|
|
||||||
|
app.get('/api/stop/:routeId', async (req, res) => {
|
||||||
|
const routeId = req.params.routeId;
|
||||||
|
res.json(await gtfsRealtime.getStopsByRoute(routeId))
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
app.listen(port, () => {
|
||||||
|
console.log(`Example app listening on port ${port}`)
|
||||||
|
})
|
||||||
@@ -1,27 +0,0 @@
|
|||||||
[
|
|
||||||
{
|
|
||||||
"route_id": "701",
|
|
||||||
"start_location": "Draper City Center",
|
|
||||||
"end_location": "Salt Lake Central"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"route_id": "703",
|
|
||||||
"start_location": "Airport Station",
|
|
||||||
"end_location": "West Valley Central Station"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"route_id": "704",
|
|
||||||
"start_location": "Daybreak Parkway Station",
|
|
||||||
"end_location": "U. Of U. Medical Center Station"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"route_id": "720",
|
|
||||||
"start_location": "Provo Central Station",
|
|
||||||
"end_location": "Ogden Station"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"route_id": "750",
|
|
||||||
"start_location": "Fairmont Station",
|
|
||||||
"end_location": "Central Pointe Station"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -1,68 +0,0 @@
|
|||||||
[
|
|
||||||
{
|
|
||||||
"vehicleId": "1003B",
|
|
||||||
"routeNum": "701",
|
|
||||||
"routeName": "Blue Line",
|
|
||||||
"destination": "To Salt Lake Central",
|
|
||||||
"bearing": 0.3,
|
|
||||||
"location": {
|
|
||||||
"latitude": 40.67723,
|
|
||||||
"longitude": -111.8941
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"vehicleId": "1016B",
|
|
||||||
"routeNum": "701",
|
|
||||||
"routeName": "Blue Line",
|
|
||||||
"destination": "To Draper",
|
|
||||||
"bearing": 89.9,
|
|
||||||
"location": {
|
|
||||||
"latitude": 40.76929,
|
|
||||||
"longitude": -111.9005
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"vehicleId": "1006A",
|
|
||||||
"routeNum": "701",
|
|
||||||
"routeName": "Blue Line",
|
|
||||||
"destination": "To Salt Lake Central",
|
|
||||||
"bearing": 358.6,
|
|
||||||
"location": {
|
|
||||||
"latitude": 40.76017,
|
|
||||||
"longitude": -111.8911
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"vehicleId": "1028A",
|
|
||||||
"routeNum": "701",
|
|
||||||
"routeName": "Blue Line",
|
|
||||||
"destination": "To Draper",
|
|
||||||
"bearing": 179.9,
|
|
||||||
"location": {
|
|
||||||
"latitude": 40.74292,
|
|
||||||
"longitude": -111.8969
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"vehicleId": "1004A",
|
|
||||||
"routeNum": "701",
|
|
||||||
"routeName": "Blue Line",
|
|
||||||
"destination": "To Salt Lake Central",
|
|
||||||
"bearing": 349,
|
|
||||||
"location": {
|
|
||||||
"latitude": 40.57289,
|
|
||||||
"longitude": -111.8852
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"vehicleId": "1008A",
|
|
||||||
"routeNum": "701",
|
|
||||||
"routeName": "Blue Line",
|
|
||||||
"destination": "To Draper",
|
|
||||||
"bearing": 180.6,
|
|
||||||
"location": {
|
|
||||||
"latitude": 40.65089,
|
|
||||||
"longitude": -111.8982
|
|
||||||
}
|
|
||||||
}
|
|
||||||
]
|
|
||||||
@@ -1,48 +0,0 @@
|
|||||||
import express from "express";
|
|
||||||
import * as dal from "../dal/staticDal.js";
|
|
||||||
|
|
||||||
const router = express.Router();
|
|
||||||
|
|
||||||
router.get("/", (req, res) => {
|
|
||||||
let vehicles = dal.getVehicles();
|
|
||||||
|
|
||||||
const {routeNum, routeName, destination, minLat, maxLat, minLng, maxLng, limit} = req.query;
|
|
||||||
|
|
||||||
if (routeNum) vehicles = vehicles.filter(v => String(v.routeNum) === String(routeNum));
|
|
||||||
if (routeName) vehicles = vehicles.filter(v => v.routeName && v.routeName.toLowerCase().includes(String(routeName).toLowerCase()));
|
|
||||||
if (destination) vehicles = vehicles.filter(v => v.destination && v.destination.toLowerCase().includes(String(destination).toLowerCase()));
|
|
||||||
|
|
||||||
if (minLat || maxLat || minLng || maxLng) {
|
|
||||||
const minLatN = parseFloat(minLat ?? -90);
|
|
||||||
const maxLatN = parseFloat(maxLat ?? 90);
|
|
||||||
const minLngN = parseFloat(minLng ?? -180);
|
|
||||||
const maxLngN = parseFloat(maxLng ?? 180);
|
|
||||||
|
|
||||||
vehicles = vehicles.filter(v => {
|
|
||||||
const lat = parseFloat(v.location?.latitude);
|
|
||||||
const lng = parseFloat(v.location?.longitude);
|
|
||||||
|
|
||||||
return lat >= minLatN && lat <= maxLatN && lng >= minLngN && lng <= maxLngN;
|
|
||||||
});
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
let returned = vehicles.length;
|
|
||||||
if (limit) {
|
|
||||||
const lim = Math.max(1, Math.min(1000, parseInt(limit, 10) || 100));
|
|
||||||
|
|
||||||
vehicles = vehicles.slice(0, lim);
|
|
||||||
returned = vehicles.length;
|
|
||||||
}
|
|
||||||
|
|
||||||
res.json({meta: {returned}, data: vehicles});
|
|
||||||
});
|
|
||||||
|
|
||||||
router.get("/:id", (req, res) => {
|
|
||||||
const v = dal.getVehicleById(req.params.id);
|
|
||||||
if (!v) return res.status(404).json({error: "Vehicle Was Not Found"});
|
|
||||||
|
|
||||||
res.json({data: v});
|
|
||||||
});
|
|
||||||
|
|
||||||
export default router;
|
|
||||||
12
start.js
12
start.js
@@ -1,12 +0,0 @@
|
|||||||
import express from "express";
|
|
||||||
import app from "./app.js";
|
|
||||||
|
|
||||||
const PORT = process.env.PORT || 7653;
|
|
||||||
|
|
||||||
// Serve static files from "public" folder
|
|
||||||
app.use(express.static('public'));
|
|
||||||
|
|
||||||
// Start the server
|
|
||||||
app.listen(PORT, () => {
|
|
||||||
console.log(`Transit API running at http://localhost:${PORT}`);
|
|
||||||
});
|
|
||||||
324
tools/gtfsData.js
Normal file
324
tools/gtfsData.js
Normal file
@@ -0,0 +1,324 @@
|
|||||||
|
const GtfsRealtimeBindings = require("gtfs-realtime-bindings");
|
||||||
|
const unzipper = require("unzipper");
|
||||||
|
const parse = require("csv-parse/sync").parse;
|
||||||
|
|
||||||
|
const TRAIN_ROUTES = ["701", "703", "704", "720", "750"];
|
||||||
|
const URL = "https://apps.rideuta.com/tms/gtfs/";
|
||||||
|
const URL_ROUTES = ["Vehicle", "TripUpdate", "Alert"];
|
||||||
|
const RT_POLLING = 3000;
|
||||||
|
|
||||||
|
let gtfs_data = null;
|
||||||
|
let gtfs_rt_v = null;
|
||||||
|
let gtfs_rt_t = null;
|
||||||
|
let gtfs_rt_a = null;
|
||||||
|
let gtfs_timestamp = null;
|
||||||
|
let updatePromise = null;
|
||||||
|
|
||||||
|
|
||||||
|
async function applyTripUpdates(stopTimes, tripId) {
|
||||||
|
await updateGtfsRt();
|
||||||
|
if (!gtfs_rt_t) return stopTimes;
|
||||||
|
|
||||||
|
const tripUpdateEntity = gtfs_rt_t.entity.find(e => e.tripUpdate?.trip?.tripId === tripId);
|
||||||
|
if (!tripUpdateEntity || !tripUpdateEntity.tripUpdate) return stopTimes;
|
||||||
|
|
||||||
|
const updates = tripUpdateEntity.tripUpdate.stopTimeUpdate || [];
|
||||||
|
|
||||||
|
const updateMap = new Map();
|
||||||
|
updates.forEach(u => {
|
||||||
|
if (u.stopId) updateMap.set(u.stopId, u);
|
||||||
|
});
|
||||||
|
|
||||||
|
return stopTimes.map(st => {
|
||||||
|
const update = updateMap.get(st.stop_id);
|
||||||
|
if (!update) return { ...st };
|
||||||
|
|
||||||
|
return {
|
||||||
|
...st,
|
||||||
|
arrival: update.arrival ? {
|
||||||
|
time: update.arrival.time,
|
||||||
|
delay: update.arrival.delay
|
||||||
|
} : undefined,
|
||||||
|
departure: update.departure ? {
|
||||||
|
time: update.departure.time,
|
||||||
|
delay: update.departure.delay
|
||||||
|
} : undefined,
|
||||||
|
scheduleRelationship: update.scheduleRelationship || 'SCHEDULED'
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadGtfsStaticInMemory() {
|
||||||
|
const url = "https://apps.rideuta.com/tms/gtfs/Static";
|
||||||
|
const res = await fetch(url);
|
||||||
|
if (!res.ok) throw new Error(`Failed: ${res.status} ${res.statusText}`);
|
||||||
|
|
||||||
|
const zipBuffer = Buffer.from(await res.arrayBuffer());
|
||||||
|
const directory = await unzipper.Open.buffer(zipBuffer);
|
||||||
|
const gtfs = {};
|
||||||
|
|
||||||
|
for (const entry of directory.files) {
|
||||||
|
if (!entry.path.endsWith(".txt")) continue; // only GTFS .txt files
|
||||||
|
|
||||||
|
const fileBuffer = await entry.buffer();
|
||||||
|
const text = fileBuffer.toString("utf8");
|
||||||
|
const rows = parse(text, { columns: true, skip_empty_lines: true });
|
||||||
|
const name = entry.path.replace(".txt", "");
|
||||||
|
gtfs[name] = rows;
|
||||||
|
}
|
||||||
|
|
||||||
|
return gtfs;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getTrains() {
|
||||||
|
await updateGtfsRt();
|
||||||
|
|
||||||
|
if (!gtfs_rt_v) return [];
|
||||||
|
const trains = [];
|
||||||
|
gtfs_rt_v.entity.forEach(entity => {
|
||||||
|
if (entity.vehicle) {
|
||||||
|
const tripId = entity.vehicle.trip.tripId;
|
||||||
|
const trip = gtfs_data.trips.find(t => t.trip_id === tripId);
|
||||||
|
//const stopTimes = gtfs_data.stop_times.filter(st => st.trip_id === tripId);
|
||||||
|
|
||||||
|
let route = null;
|
||||||
|
if (trip) {
|
||||||
|
route = gtfs_data.routes.find(r => r.route_id === trip.route_id);
|
||||||
|
}
|
||||||
|
if (route && TRAIN_ROUTES.find(r => r === route.route_short_name)) {
|
||||||
|
trains.push({
|
||||||
|
vehicle: entity.vehicle,
|
||||||
|
trip,
|
||||||
|
//stopTimes,
|
||||||
|
route
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return trains;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getTrainsByRoute(route) {
|
||||||
|
const trains = await getTrains();
|
||||||
|
return trains.filter(t => t.route && t.route.route_short_name === route);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getBuses() {
|
||||||
|
await updateGtfsRt();
|
||||||
|
|
||||||
|
if (!gtfs_rt_v) return [];
|
||||||
|
const buses = [];
|
||||||
|
|
||||||
|
gtfs_rt_v.entity.forEach(entity => {
|
||||||
|
if (entity.vehicle) {
|
||||||
|
const tripId = entity.vehicle.trip.tripId;
|
||||||
|
const trip = gtfs_data.trips.find(t => t.trip_id === tripId);
|
||||||
|
//const stopTimes = gtfs_data.stop_times.filter(st => st.trip_id === tripId);
|
||||||
|
|
||||||
|
let route = null;
|
||||||
|
if (trip) {
|
||||||
|
route = gtfs_data.routes.find(r => r.route_id === trip.route_id);
|
||||||
|
}
|
||||||
|
if (route && !TRAIN_ROUTES.find(r => r === route.route_short_name)) {
|
||||||
|
buses.push({
|
||||||
|
vehicle: entity.vehicle,
|
||||||
|
trip,
|
||||||
|
//stopTimes,
|
||||||
|
route
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
return buses;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getBusesByRoute(route) {
|
||||||
|
const buses = await getBuses();
|
||||||
|
return buses.filter(b => b.route && b.route.route_short_name === route);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function updateGtfsRt() {
|
||||||
|
if (!gtfs_data) gtfs_data = await loadGtfsStaticInMemory();
|
||||||
|
|
||||||
|
const now = Date.now();
|
||||||
|
if (!gtfs_timestamp || now - gtfs_timestamp >= RT_POLLING) {
|
||||||
|
if (!updatePromise) {
|
||||||
|
updatePromise = (async () => {
|
||||||
|
gtfs_timestamp = now;
|
||||||
|
try {
|
||||||
|
[gtfs_rt_v, gtfs_rt_t, gtfs_rt_a] = await Promise.all([
|
||||||
|
loadGtfsRt("Vehicle"),
|
||||||
|
loadGtfsRt("TripUpdate"),
|
||||||
|
loadGtfsRt("Alert")
|
||||||
|
]);
|
||||||
|
} catch (e) {
|
||||||
|
console.error("Failed to update RT feeds:", e);
|
||||||
|
} finally {
|
||||||
|
updatePromise = null;
|
||||||
|
}
|
||||||
|
})();
|
||||||
|
}
|
||||||
|
await updatePromise;
|
||||||
|
}
|
||||||
|
return { gtfs_rt_v,gtfs_rt_t,gtfs_rt_a };
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadGtfsRt(feedType = URL_ROUTES[0]) {
|
||||||
|
const response = await fetch(URL + feedType);
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
|
||||||
|
}
|
||||||
|
const buffer = new Uint8Array(await response.arrayBuffer());
|
||||||
|
return GtfsRealtimeBindings.transit_realtime.FeedMessage.decode(buffer);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getAlerts() {
|
||||||
|
await updateGtfsRt();
|
||||||
|
|
||||||
|
const alerts = [];
|
||||||
|
if (!gtfs_rt_a) return [];
|
||||||
|
const routeMap = new Map(gtfs_data.routes.map(r => [r.route_id, r]));
|
||||||
|
|
||||||
|
gtfs_rt_a.entity.forEach(entity => {
|
||||||
|
|
||||||
|
const informedEntities = entity.alert?.informedEntity || [];
|
||||||
|
|
||||||
|
const routes = informedEntities
|
||||||
|
.map(e => e.routeId)
|
||||||
|
.filter(Boolean)
|
||||||
|
.map(routeId => routeMap.get(routeId))
|
||||||
|
.filter(Boolean);
|
||||||
|
|
||||||
|
alerts.push({ alert: entity.alert, routes });
|
||||||
|
});
|
||||||
|
|
||||||
|
return alerts;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getAlertsByRoute(route) {
|
||||||
|
const alerts = await getAlerts();
|
||||||
|
return alerts.filter(a =>
|
||||||
|
a.routes && a.routes.some(r => r.route_short_name === route)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getScheduleByRoute(route) {
|
||||||
|
await updateGtfsRt();
|
||||||
|
|
||||||
|
const matchingRoutes = gtfs_data.routes.filter(r => r.route_short_name === route);
|
||||||
|
if (!matchingRoutes.length) return [];
|
||||||
|
|
||||||
|
const trips = gtfs_data.trips.filter(t => matchingRoutes.some(r => r.route_id === t.route_id));
|
||||||
|
const schedulePromises = trips.map(async trip => {
|
||||||
|
let stopTimes = gtfs_data.stop_times.filter(st => st.trip_id === trip.trip_id);
|
||||||
|
stopTimes = await applyTripUpdates(stopTimes, trip.trip_id);
|
||||||
|
const routeObj = matchingRoutes.find(r => r.route_id === trip.route_id);
|
||||||
|
|
||||||
|
return { trip, stopTimes, route: routeObj };
|
||||||
|
});
|
||||||
|
|
||||||
|
return await Promise.all(schedulePromises);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getScheduleByStationId(stopId) {
|
||||||
|
await updateGtfsRt();
|
||||||
|
|
||||||
|
const stopTimes = gtfs_data.stop_times.filter(st => st.stop_id === stopId);
|
||||||
|
const schedulePromises = stopTimes.map(async st => {
|
||||||
|
const trip = gtfs_data.trips.find(t => t.trip_id === st.trip_id);
|
||||||
|
const route = trip ? gtfs_data.routes.find(r => r.route_id === trip.route_id) : null;
|
||||||
|
const updatedStopTimes = await applyTripUpdates([st], st.trip_id);
|
||||||
|
|
||||||
|
return { stopTime: updatedStopTimes[0], trip, route };
|
||||||
|
});
|
||||||
|
|
||||||
|
return await Promise.all(schedulePromises);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getShapeByRoute(route) {
|
||||||
|
if (!gtfs_data) {
|
||||||
|
await loadGtfsStaticInMemory();
|
||||||
|
};
|
||||||
|
|
||||||
|
const routes = gtfs_data.routes.filter(r => r.route_short_name === route);
|
||||||
|
if (!routes.length) return [];
|
||||||
|
|
||||||
|
const shapeIds = new Set();
|
||||||
|
gtfs_data.trips.forEach(trip => {
|
||||||
|
if (routes.some(r => r.route_id === trip.route_id) && trip.shape_id) {
|
||||||
|
shapeIds.add(trip.shape_id);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const shapes = [];
|
||||||
|
shapeIds.forEach(shapeId => {
|
||||||
|
const points = gtfs_data.shapes
|
||||||
|
.filter(s => s.shape_id === shapeId)
|
||||||
|
.sort((a, b) => parseInt(a.shape_pt_sequence) - parseInt(b.shape_pt_sequence))
|
||||||
|
.map(s => ({ lat: parseFloat(s.shape_pt_lat), lon: parseFloat(s.shape_pt_lon) }));
|
||||||
|
|
||||||
|
shapes.push({ shapeId, points });
|
||||||
|
});
|
||||||
|
|
||||||
|
return shapes;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getRoutes() {
|
||||||
|
if (!gtfs_data) {
|
||||||
|
gtfs_data = await loadGtfsStaticInMemory();
|
||||||
|
}
|
||||||
|
return gtfs_data.routes || [];
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getStops() {
|
||||||
|
if (!gtfs_data) {
|
||||||
|
gtfs_data = await loadGtfsStaticInMemory();
|
||||||
|
}
|
||||||
|
return gtfs_data.stops || [];
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getStopsByRoute(route) {
|
||||||
|
if (!gtfs_data) {
|
||||||
|
gtfs_data = await loadGtfsStaticInMemory();
|
||||||
|
}
|
||||||
|
|
||||||
|
const matchingRoutes = gtfs_data.routes.filter(r => r.route_short_name === route);
|
||||||
|
if (!matchingRoutes.length) return [];
|
||||||
|
|
||||||
|
const trips = gtfs_data.trips.filter(t => matchingRoutes.some(r => r.route_id === t.route_id));
|
||||||
|
const stopIds = new Set(
|
||||||
|
gtfs_data.stop_times
|
||||||
|
.filter(st => trips.some(trip => trip.trip_id === st.trip_id))
|
||||||
|
.map(st => st.stop_id)
|
||||||
|
);
|
||||||
|
|
||||||
|
return gtfs_data.stops.filter(s => stopIds.has(s.stop_id));
|
||||||
|
}
|
||||||
|
|
||||||
|
setInterval(async () => {
|
||||||
|
console.log("Refreshing static GTFS...");
|
||||||
|
gtfs_data = await loadGtfsStaticInMemory();
|
||||||
|
}, 24 * 60 * 60 * 1000);
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
getStops,
|
||||||
|
getStopsByRoute,
|
||||||
|
getRoutes,
|
||||||
|
getTrains,
|
||||||
|
getBuses,
|
||||||
|
getTrainsByRoute,
|
||||||
|
getBusesByRoute,
|
||||||
|
getAlerts,
|
||||||
|
getAlertsByRoute,
|
||||||
|
getScheduleByRoute,
|
||||||
|
getScheduleByStationId,
|
||||||
|
getShapeByRoute,
|
||||||
|
|
||||||
|
updateGtfsRt,
|
||||||
|
loadGtfsStaticInMemory,
|
||||||
|
gtfs_rt_v,
|
||||||
|
gtfs_rt_t,
|
||||||
|
gtfs_rt_a,
|
||||||
|
}
|
||||||
69024
tripUpdates.json
69024
tripUpdates.json
File diff suppressed because it is too large
Load Diff
6689
vehicles.json
6689
vehicles.json
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user