380 lines
13 KiB
JavaScript
380 lines
13 KiB
JavaScript
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: [32, 32],
|
|
iconAnchor: [15, 15]
|
|
});
|
|
|
|
const busIcon = L.divIcon({
|
|
html: '🚌',
|
|
className: "bus-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) {
|
|
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", async () => {
|
|
stopsVisible = !stopsVisible;
|
|
const btn = document.getElementById("toggleStops");
|
|
btn.textContent = stopsVisible ? "Hide Stops" : "Show Stops";
|
|
stopMarkers.clearLayers();
|
|
|
|
if (stopsVisible) {
|
|
await loadStops(currentRoute || null);
|
|
}
|
|
});
|
|
|
|
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());
|
|
|
|
const parsed = data.map(item => {
|
|
const raw = item.stopTime?.arrival_time?.trim();
|
|
if (!raw) return null;
|
|
|
|
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);
|
|
|
|
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);
|
|
|
|
const seen = new Set();
|
|
const unique = [];
|
|
|
|
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;
|
|
}
|
|
|
|
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>`;
|
|
|
|
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 timeDisplay = mins <= 30
|
|
? `<strong style="color:#d00;">${timeStr} (${mins === 0 ? "Due" : mins + " min"})</strong>`
|
|
: timeStr;
|
|
|
|
const soon = mins <= 5 ? ' <small style="opacity:0.8;">(soon)</small>' : '';
|
|
|
|
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:${color}; color:${getContrastColor(color)}; font-weight:bold;">
|
|
${p.route}${soon}
|
|
</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()} • All duplicates removed
|
|
</small>`;
|
|
|
|
scheduleDiv.innerHTML = html;
|
|
|
|
} catch (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);
|
|
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) {
|
|
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];
|
|
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) return;
|
|
try {
|
|
const res = await fetch(`${API}/shape/${routeId}`);
|
|
const shapes = await res.json();
|
|
const res2 = await fetch(`${API}/routes`);
|
|
const routes = await res2.json();
|
|
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);
|
|
}
|
|
}
|
|
|
|
|
|
document.getElementById("routeSelect").addEventListener("change", async (e) => {
|
|
currentRoute = e.target.value;
|
|
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();
|
|
});
|
|
|
|
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);
|
|
});
|
|
}
|
|
} 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>";
|
|
}
|
|
}
|
|
|
|
document.getElementById("refresh").addEventListener("click", updateVehicles);
|
|
|
|
(async function init() {
|
|
try {
|
|
await loadAllRoutes();
|
|
await loadStops();
|
|
updateVehicles();
|
|
setInterval(updateVehicles, 10000);
|
|
} catch (err) {
|
|
console.error("Init failed:", err);
|
|
document.getElementById("status").textContent = "Failed to initialize";
|
|
}
|
|
})();
|