Full final production #6

Merged
nspackman merged 3 commits from v2 into main 2025-12-04 14:39:02 -08:00
12 changed files with 399 additions and 348 deletions
Showing only changes of commit 6b746d466f - Show all commits

View File

@@ -1,49 +1,28 @@
# ---- 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
COPY . .
LABEL maintainer="your-email@example.com"
LABEL org.opencontainers.image.source="https://github.com/yourname/uta-gtfs-mysql"
RUN npm run build || echo "No build step needed"
# 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
FROM node:20-alpine AS production
WORKDIR /app
# Copy node_modules from builder
COPY --from=builder /app/node_modules ./node_modules
COPY --from=builder /app ./
# Copy app source
COPY . .
RUN addgroup -g 1001 -S nodejs && \
adduser -S uta-sync -u 1001
RUN chown -R utauser:nodejs /app
USER utauser
USER uta-sync
# 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 1001
EXPOSE 3000
ENTRYPOINT ["dumb-init", "--"]
CMD ["node", "server.js"]

View File

@@ -1,45 +1,19 @@
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
redis:
image: redis:7-alpine
container_name: my-redis
ports:
- "3306:3306"
healthcheck:
test: ["CMD", "mysqladmin", "ping", "-h", "localhost", "-puta", "--password=uta123"]
interval: 10s
timeout: 5s
retries: 5
- "6379:6379"
restart: unless-stopped
uta-sync:
app:
build: .
container_name: uta-sync
container_name: my-express-app
ports:
- "1001:1001"
restart: unless-stopped
environment:
- DATABASE_URL=mysql://uta:uta123@uta-mysql:3306/uta
- HEALTH_PORT=3000
- NODE_ENV=production
REDIS_HOST: redis
REDIS_PORT: 6379
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:
- redis

View File

@@ -1,98 +0,0 @@
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
);

87
package-lock.json generated
View File

@@ -17,6 +17,7 @@
"node-fetch": "^3.3.2",
"parse": "^7.1.2",
"pg": "^8.16.3",
"redis": "^5.10.0",
"unzipper": "^0.12.3"
}
},
@@ -154,6 +155,67 @@
"integrity": "sha512-Vvn3zZrhQZkkBE8LSuW3em98c0FwgO4nxzv6OdSxPKJIEKY2bGbHn+mhGIPerzI4twdxaP8/0+06HBpwf345Lw==",
"license": "BSD-3-Clause"
},
"node_modules/@redis/bloom": {
"version": "5.10.0",
"resolved": "https://registry.npmjs.org/@redis/bloom/-/bloom-5.10.0.tgz",
"integrity": "sha512-doIF37ob+l47n0rkpRNgU8n4iacBlKM9xLiP1LtTZTvz8TloJB8qx/MgvhMhKdYG+CvCY2aPBnN2706izFn/4A==",
"license": "MIT",
"engines": {
"node": ">= 18"
},
"peerDependencies": {
"@redis/client": "^5.10.0"
}
},
"node_modules/@redis/client": {
"version": "5.10.0",
"resolved": "https://registry.npmjs.org/@redis/client/-/client-5.10.0.tgz",
"integrity": "sha512-JXmM4XCoso6C75Mr3lhKA3eNxSzkYi3nCzxDIKY+YOszYsJjuKbFgVtguVPbLMOttN4iu2fXoc2BGhdnYhIOxA==",
"license": "MIT",
"peer": true,
"dependencies": {
"cluster-key-slot": "1.1.2"
},
"engines": {
"node": ">= 18"
}
},
"node_modules/@redis/json": {
"version": "5.10.0",
"resolved": "https://registry.npmjs.org/@redis/json/-/json-5.10.0.tgz",
"integrity": "sha512-B2G8XlOmTPUuZtD44EMGbtoepQG34RCDXLZbjrtON1Djet0t5Ri7/YPXvL9aomXqP8lLTreaprtyLKF4tmXEEA==",
"license": "MIT",
"engines": {
"node": ">= 18"
},
"peerDependencies": {
"@redis/client": "^5.10.0"
}
},
"node_modules/@redis/search": {
"version": "5.10.0",
"resolved": "https://registry.npmjs.org/@redis/search/-/search-5.10.0.tgz",
"integrity": "sha512-3SVcPswoSfp2HnmWbAGUzlbUPn7fOohVu2weUQ0S+EMiQi8jwjL+aN2p6V3TI65eNfVsJ8vyPvqWklm6H6esmg==",
"license": "MIT",
"engines": {
"node": ">= 18"
},
"peerDependencies": {
"@redis/client": "^5.10.0"
}
},
"node_modules/@redis/time-series": {
"version": "5.10.0",
"resolved": "https://registry.npmjs.org/@redis/time-series/-/time-series-5.10.0.tgz",
"integrity": "sha512-cPkpddXH5kc/SdRhF0YG0qtjL+noqFT0AcHbQ6axhsPsO7iqPi1cjxgdkE9TNeKiBUUdCaU1DbqkR/LzbzPBhg==",
"license": "MIT",
"engines": {
"node": ">= 18"
},
"peerDependencies": {
"@redis/client": "^5.10.0"
}
},
"node_modules/@types/linkify-it": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/@types/linkify-it/-/linkify-it-5.0.0.tgz",
@@ -362,6 +424,15 @@
"url": "https://github.com/chalk/chalk?sponsor=1"
}
},
"node_modules/cluster-key-slot": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/cluster-key-slot/-/cluster-key-slot-1.1.2.tgz",
"integrity": "sha512-RMr0FhtfXemyinomL4hrWcYJxmX6deFdCxpJzhDttxgO1+bcCnkk+9drydLVDmAMG7NE6aN/fl4F7ucU/90gAA==",
"license": "Apache-2.0",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/color-convert": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
@@ -1785,6 +1856,22 @@
"util-deprecate": "~1.0.1"
}
},
"node_modules/redis": {
"version": "5.10.0",
"resolved": "https://registry.npmjs.org/redis/-/redis-5.10.0.tgz",
"integrity": "sha512-0/Y+7IEiTgVGPrLFKy8oAEArSyEJkU0zvgV5xyi9NzNQ+SLZmyFbUsWIbgPcd4UdUh00opXGKlXJwMmsis5Byw==",
"license": "MIT",
"dependencies": {
"@redis/bloom": "5.10.0",
"@redis/client": "5.10.0",
"@redis/json": "5.10.0",
"@redis/search": "5.10.0",
"@redis/time-series": "5.10.0"
},
"engines": {
"node": ">= 18"
}
},
"node_modules/requizzle": {
"version": "0.2.4",
"resolved": "https://registry.npmjs.org/requizzle/-/requizzle-0.2.4.tgz",

View File

@@ -19,6 +19,7 @@
"node-fetch": "^3.3.2",
"parse": "^7.1.2",
"pg": "^8.16.3",
"redis": "^5.10.0",
"unzipper": "^0.12.3"
}
}

View File

@@ -1,49 +0,0 @@
const map = L.map("map").setView([40.7608, -111.8910], 12);
L.tileLayer("https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png", {
attribution: '© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a>',
}).addTo(map);
const API_URL = "http://localhost:7653/api/v0/";
const trainEmojiIcon = L.divIcon({ html: "🔵", className: "", iconSize: [64,64], iconAnchor: [16,32], popupAnchor: [0,-32] });
const stopsEmojiIcon = L.divIcon({ html: "🫃", className: "", iconSize: [64,64], iconAnchor: [16,32], popupAnchor: [0,-32] });
function addMarker(lat, lon, content, icon) {
if (!isNaN(lat) && !isNaN(lon)) {
const marker = L.marker([lat, lon], { icon: icon }).addTo(map);
if (content) marker.bindPopup(content);
} else {
console.warn("Invalid coordinates:", latitude, longitude);
}
}
function getTrainsByRoute(route) {
fetch(API_URL + 'vehicles')
.then(res => res.json())
.then(data => {
const trains = data.data;
const filtered = route ? trains.filter(t => t.routeId == route) : trains;
filtered.forEach(t => {
addMarker(t.location.latitude, t.location.longitude, t.routeName + ": Vehicle " + t.vehicleId, trainEmojiIcon);
});
})
.catch(err => console.error("Error fetching trains:", err));
}
function getStopsByRoute(route) {
fetch(API_URL + 'stops/' + route)
.then(res => res.json())
.then(data => {
const stops = data.data;
stops.forEach(s => {
const lat = parseFloat(s.stop_lat);
const lon = parseFloat(s.stop_lon);
addMarker(lat,lon, s.stop_name + " - " + s.stop_desc, stopsEmojiIcon);
});
})
.catch(err => console.error("Error fetching stops:", err));
}
getStopsByRoute("701");
getTrainsByRoute();

View File

@@ -19,6 +19,7 @@
<div class="controls">
<select id="routeSelect">
<option value="">All Trains & Buses</option>
<option value="LRTC">LRT + Communter</option>
<option value="701">Blue Line (701)</option>
<option value="703">Red Line (703)</option>
<option value="704">Green Line (704)</option>

View File

@@ -4,18 +4,16 @@ 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],
iconSize: [32, 32],
iconAnchor: [15, 15]
});
const busIcon = L.divIcon({
html: '🚌',
className: "train-icon",
className: "bus-icon",
iconSize: [28, 28],
iconAnchor: [14, 14]
});
@@ -24,7 +22,6 @@ 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 = {};
@@ -32,7 +29,6 @@ let stopsVisible = true;
let currentRoute = "";
async function loadStops(routeId = null) {
stopMarkers.clearLayers();
if (!stopsVisible) return;
try {
@@ -63,19 +59,14 @@ async function loadStops(routeId = null) {
}
}
document.getElementById("toggleStops").addEventListener("click", () => {
document.getElementById("toggleStops").addEventListener("click", async () => {
stopsVisible = !stopsVisible;
const btn = document.getElementById("toggleStops");
btn.textContent = stopsVisible ? "Hide Stops" : "Show Stops";
stopMarkers.clearLayers();
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();
await loadStops(currentRoute || null);
}
});
@@ -93,92 +84,99 @@ async function showStopSchedule(stopId, stopName) {
}
const now = new Date();
const today = new Date(now.getFullYear(), now.getMonth(), now.getDate()); // midnight today
const today = new Date(now.getFullYear(), now.getMonth(), now.getDate());
// 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
const parsed = data.map(item => {
const raw = item.stopTime?.arrival_time?.trim();
if (!raw) return null;
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);
let [h, m, s] = raw.split(":").map(Number);
const date = new Date(today);
if (h >= 24) { h -= 24; date.setDate(date.getDate() + 1); }
date.setHours(h, m, s || 0, 0);
return { ...item, arrivalDate };
})
.filter(item => item && item.arrivalDate > now) // remove past or invalid
.sort((a, b) => a.arrivalDate - b.arrivalDate);
if (date <= now) return null;
return {
route: (item.route?.route_short_name || item.route?.route_id || "???").toString(),
headsign: (item.trip?.trip_headsign || "").trim(),
time: date.getTime(),
tripId: item.trip?.trip_id || null,
vehicleId: item.vehicle?.vehicle?.id || null
};
}).filter(Boolean);
// 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;
});
const unique = [];
if (uniqueUpcoming.length === 0) {
for (const p of parsed) {
if (p.tripId && seen.has(`T${p.tripId}`)) continue;
const key2 = `${p.route}|${p.headsign}|${p.time}`;
if (seen.has(key2)) continue;
const key3 = p.vehicleId ? `V${p.vehicleId}|${p.time}` : null;
if (key3 && seen.has(key3)) continue;
if (p.tripId) seen.add(`T${p.tripId}`);
seen.add(key2);
if (key3) seen.add(key3);
unique.push(p);
}
unique.sort((a, b) => a.time - b.time);
if (unique.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>`;
<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 || "—";
unique.forEach(p => {
const mins = Math.round((p.time - now.getTime()) / 60000);
const timeStr = new Date(p.time).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
const minutesUntil = Math.round((arrivalDate - now) / 60000);
const displayTime = arrivalDate.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
const timeDisplay = mins <= 30
? `<strong style="color:#d00;">${timeStr} (${mins === 0 ? "Due" : mins + " min"})</strong>`
: timeStr;
const timeDisplay = minutesUntil <= 30
? `<strong style="color:#d00;">${displayTime} (${minutesUntil === 0 ? "Due" : minutesUntil + " min"})</strong>`
: displayTime;
const soon = mins <= 5 ? ' <small style="opacity:0.8;">(soon)</small>' : '';
const rowColor = route?.route_color && route.route_color !== "000000"
? `#${route.route_color}`
: "#666";
const originalItem = data.find(d =>
d.trip?.trip_id === p.tripId ||
(d.route?.route_short_name === p.route && (d.trip?.trip_headsign || "").trim() === p.headsign)
);
const color = originalItem?.route?.route_color && originalItem.route.route_color !== "000000"
? `#${originalItem.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 style="padding:6px; text-align:center; background:${color}; color:${getContrastColor(color)}; font-weight:bold;">
${p.route}${soon}
</td>
<td style="padding:6px;">${headsign}</td>
<td style="padding:6px;">${p.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()}
Updated ${now.toLocaleTimeString()} • All duplicates removed
</small>`;
scheduleDiv.innerHTML = html;
} catch (err) {
console.error("Error fetching stop schedule:", err);
console.error(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);
@@ -195,16 +193,20 @@ async function updateVehicles() {
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 trainRes = await fetch(`${API}/train`);
trains = await trainRes.json();
} else if (currentRoute == "LRTC") {
const trainRes = await fetch(`${API}/train`);
trains = await trainRes.json();
} else if (["701", "703", "704", "720", "750"].includes(currentRoute)) {
const trainRes = await fetch(`${API}/train/${currentRoute}`);
trains = await trainRes.json();
} else {
const busRes = await fetch(`${API}/bus/${currentRoute}`);
buses = await busRes.json();
}
const allVehicles = [...trains, ...buses];
@@ -244,65 +246,70 @@ async function updateVehicles() {
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;
}
if (!routeId) 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}`;
const route = routes.find(r => r.route_short_name == routeId);
const routeHexColor = route?.route_color ? `#${route.route_color}` : "#888";
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); }
} 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);
console.log("Selected route:", currentRoute);
routeShapes.clearLayers();
stopMarkers.clearLayers();
if (!currentRoute) {
await loadAllRoutes();
await loadStops();
} else if (currentRoute === "LRTC") {
const combinedRoutes = ["750", "720", "704", "703", "701"];
for (const routeId of combinedRoutes) {
await loadRouteShape(routeId);
await loadStops(routeId);
}
} else {
await loadRouteShape(currentRoute);
await 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`);
@@ -319,19 +326,9 @@ async function loadAllRoutes() {
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);
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);
}
@@ -350,17 +347,9 @@ async function showVehicleSchedule(vehicleId) {
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>
`;
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();
@@ -369,20 +358,22 @@ async function showVehicleSchedule(vehicleId) {
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(() => {
document.getElementById("refresh").addEventListener("click", updateVehicles);
(async function init() {
try {
await loadAllRoutes();
await loadStops();
updateVehicles();
setInterval(updateVehicles, 10000); // Update every 10 seconds
})
.catch(err => {
setInterval(updateVehicles, 10000);
} catch (err) {
console.error("Init failed:", err);
document.getElementById("status").textContent = "Failed to initialize";
});
}
})();

View File

@@ -1,7 +1,9 @@
const express = require('express')
const path = require('path');
const app = express()
const gtfsRealtime = require("./tools/gtfsData")
const gtfsRealtime = require("./tools/gtfsRedis")
const redisDAL = require("./tools/redisDAL");
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
@@ -88,6 +90,29 @@ app.get('/api/stop/:routeId', async (req, res) => {
})
app.listen(port, () => {
app.listen(port, async() => {
await redisDAL.connect()
await test();
console.log(`Example app listening on port ${port}`)
})
async function test(){
console.log("stops")
await gtfsRealtime.getStops()
await gtfsRealtime.getStopsByRoute()
await gtfsRealtime.getRoutes()
console.log("trains")
await gtfsRealtime.getTrains()
await gtfsRealtime.getTrainsByRoute()
console.log("buses")
await gtfsRealtime.getBuses()
await gtfsRealtime.getBusesByRoute()
await gtfsRealtime.getShapeByRoute()
await console.log("shapes")
await gtfsRealtime.getScheduleByRoute()
await gtfsRealtime.getScheduleByStationId()
await gtfsRealtime.getAlerts()
await gtfsRealtime.getAlertsByRoute()
console.log("done")
}

View File

@@ -58,7 +58,7 @@ async function loadGtfsStaticInMemory() {
const gtfs = {};
for (const entry of directory.files) {
if (!entry.path.endsWith(".txt")) continue; // only GTFS .txt files
if (!entry.path.endsWith(".txt")) continue;
const fileBuffer = await entry.buffer();
const text = fileBuffer.toString("utf8");
@@ -315,10 +315,6 @@ module.exports = {
getScheduleByRoute,
getScheduleByStationId,
getShapeByRoute,
updateGtfsRt,
loadGtfsStaticInMemory,
gtfs_rt_v,
gtfs_rt_t,
gtfs_rt_a,
}

99
tools/gtfsRedis.js Normal file
View File

@@ -0,0 +1,99 @@
const gtfs = require("./gtfsData");
const redis = require("./redisDAL");
const CACHE_PREFIX = "gtfs_cache";
const RT_TTL = 3;
const STATIC_TTL = 24 * 3600;
async function getCached(keySuffix, fetchFn, ttl) {
await redis.connect();
const cached = await redis.get(CACHE_PREFIX, { key: keySuffix });
if (cached !== null) return cached;
const data = await fetchFn();
await redis.set(CACHE_PREFIX, { key: keySuffix }, data, ttl);
return data;
}
async function getTrains() {
return getCached("trains", gtfs.getTrains, RT_TTL);
}
async function getBuses() {
return getCached("buses", gtfs.getBuses, RT_TTL);
}
async function getTrainsByRoute(route) {
return getCached(`trains_route_${route}`, () => gtfs.getTrainsByRoute(route), RT_TTL);
}
async function getBusesByRoute(route) {
return getCached(`buses_route_${route}`, () => gtfs.getBusesByRoute(route), RT_TTL);
}
async function getAlerts() {
return getCached("alerts", gtfs.getAlerts, RT_TTL);
}
async function getAlertsByRoute(route) {
return getCached(`alerts_route_${route}`, () => gtfs.getAlertsByRoute(route), RT_TTL);
}
async function getScheduleByRoute(route) {
return getCached(`schedule_route_${route}`, () => gtfs.getScheduleByRoute(route), RT_TTL);
}
async function getScheduleByStationId(stopId) {
return getCached(`schedule_stop_${stopId}`, () => gtfs.getScheduleByStationId(stopId), RT_TTL);
}
async function getRoutes() {
return getCached("routes", gtfs.getRoutes, STATIC_TTL);
}
async function getStops() {
return getCached("stops", gtfs.getStops, STATIC_TTL);
}
async function getStopsByRoute(route) {
return getCached(`stops_route_${route}`, () => gtfs.getStopsByRoute(route), STATIC_TTL);
}
async function getShapeByRoute(route) {
return getCached(`shape_route_${route}`, () => gtfs.getShapeByRoute(route), STATIC_TTL);
}
async function updateGtfsRt() {
const data = await gtfs.updateGtfsRt();
await redis.set(CACHE_PREFIX, { key: "trains" }, await gtfs.getTrains(), RT_TTL);
await redis.set(CACHE_PREFIX, { key: "buses" }, await gtfs.getBuses(), RT_TTL);
await redis.set(CACHE_PREFIX, { key: "alerts" }, await gtfs.getAlerts(), RT_TTL);
return data;
}
async function loadGtfsStaticInMemory() {
const data = await gtfs.loadGtfsStaticInMemory();
await redis.set(CACHE_PREFIX, { key: "routes" }, data.routes || [], STATIC_TTL);
await redis.set(CACHE_PREFIX, { key: "stops" }, data.stops || [], STATIC_TTL);
return data;
}
const { gtfs_rt_v, gtfs_rt_t, gtfs_rt_a } = gtfs;
module.exports = {
getStops,
getStopsByRoute,
getRoutes,
getTrains,
getBuses,
getTrainsByRoute,
getBusesByRoute,
getAlerts,
getAlertsByRoute,
getScheduleByRoute,
getScheduleByStationId,
getShapeByRoute,
updateGtfsRt,
loadGtfsStaticInMemory,
};

45
tools/redisDAL.js Normal file
View File

@@ -0,0 +1,45 @@
const { createClient } = require("redis");
class RedisDAL {
constructor() {
this.client = createClient({ url:"redis://my-redis:6379" });
this.client.on("error", (err) => {
console.error("Redis Client Error", err);
});
}
async connect() {
if (!this.client.isOpen) {
await this.client.connect();
console.log("Connected to Redis");
}
}
generateKey(prefix, params) {
const paramString = Object.entries(params)
.sort()
.map(([k, v]) => `${k}=${v}`)
.join("&");
return `${prefix}:${paramString}`;
}
async get(prefix, params) {
const key = this.generateKey(prefix, params);
const cached = await this.client.get(key);
if (cached) return JSON.parse(cached);
return null;
}
async set(prefix, params, data, ttl = 3600) {
const key = this.generateKey(prefix, params);
await this.client.set(key, JSON.stringify(data), { EX: ttl });
}
async del(prefix, params) {
const key = this.generateKey(prefix, params);
await this.client.del(key);
}
}
module.exports = new RedisDAL();