feat: enhance bus arrival feature with location-based nearest stop resolution and map integration

This commit is contained in:
2026-06-01 14:02:51 +08:00
parent 5e6b65183b
commit 8499408179
5 changed files with 265 additions and 9 deletions
+3 -1
View File
@@ -58,7 +58,9 @@ package.json
- Dynamic endpoint explorer built from OpenAPI paths under /api/v1/*
- Bus-focused endpoint filtering (Bus and Passenger Volume tags)
- Quick bus-arrival panel with BusStopCode and optional ServiceNo
- Quick bus-arrival panel that uses your current location to resolve the nearest bus stop automatically
- Map view for current location and nearest resolved bus stop
- Optional ServiceNo filter for arrival checks
- JSON response viewer
- Arrival board card view when the response includes Services
- Server-side proxying for `/api/*` and `/openapi.json`
+5
View File
@@ -4,6 +4,9 @@
"workspaces": {
"": {
"name": "bus-sg-app",
"dependencies": {
"leaflet": "^1.9.4",
},
"devDependencies": {
"@sveltejs/adapter-auto": "^7.0.1",
"@sveltejs/kit": "^2.61.1",
@@ -171,6 +174,8 @@
"kleur": ["kleur@4.1.5", "", {}, "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ=="],
"leaflet": ["leaflet@1.9.4", "", {}, "sha512-nxS1ynzJOmOlHp+iL3FyWqK89GtNL8U8rvlMOsQdTTssxZwCXh8N2NB3GDQOL+YR3XnWyZAxwQixURb+FA74PA=="],
"locate-character": ["locate-character@3.0.0", "", {}, "sha512-SW13ws7BjaeJ6p7Q6CO2nchbYEc3X3J6WrmTTDto7yMPqVSZTUyY5Tjbid+Ab8gLnATtygYtiDIJGQRRn2ZOiA=="],
"magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="],
+3
View File
@@ -15,5 +15,8 @@
"@sveltejs/kit": "^2.61.1",
"svelte": "^5.56.0",
"vite": "^7.0.0"
},
"dependencies": {
"leaflet": "^1.9.4"
}
}
+26
View File
@@ -179,6 +179,27 @@ button:hover {
font-size: 0.86rem;
}
.location-head {
display: flex;
justify-content: space-between;
align-items: center;
gap: 0.7rem;
margin-bottom: 0.7rem;
}
.location-head .hint {
margin: 0;
}
.map-panel {
width: 100%;
height: 280px;
border: 1px solid var(--line);
border-radius: 14px;
overflow: hidden;
margin-bottom: 0.8rem;
}
.status-pill {
font-size: 0.8rem;
padding: 0.28rem 0.55rem;
@@ -265,4 +286,9 @@ button:hover {
.panel-header {
flex-wrap: wrap;
}
.location-head {
flex-direction: column;
align-items: flex-start;
}
}
+228 -8
View File
@@ -24,8 +24,16 @@
let statusState = "";
let services = [];
let busStopCode = "83139";
let serviceNo = "";
let userLocation = null;
let nearestStop = null;
let locating = false;
let locationMessage = "Location not resolved yet.";
let mapContainer;
let map;
let mapLayer;
let userMarker;
let stopMarker;
function getSelectedEndpoint() {
return endpointOptions[selectedEndpointIndex] || endpointOptions[0];
@@ -36,6 +44,23 @@
statusState = state;
}
function parseCoordinate(value) {
const num = Number(value);
return Number.isFinite(num) ? num : null;
}
function haversineMeters(lat1, lon1, lat2, lon2) {
const toRadians = (degrees) => (degrees * Math.PI) / 180;
const earthRadius = 6371000;
const dLat = toRadians(lat2 - lat1);
const dLon = toRadians(lon2 - lon1);
const a =
Math.sin(dLat / 2) * Math.sin(dLat / 2) +
Math.cos(toRadians(lat1)) * Math.cos(toRadians(lat2)) * Math.sin(dLon / 2) * Math.sin(dLon / 2);
const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
return earthRadius * c;
}
function titleizePath(path) {
return path
.replace("/api/v1/", "")
@@ -87,6 +112,181 @@
}
}
async function loadAllBusStops() {
const allStops = [];
const pageSize = 500;
let skip = 0;
while (true) {
const search = new URLSearchParams({ $top: String(pageSize), $skip: String(skip) });
const response = await fetch(`/api/v1/bus-stops?${search.toString()}`);
if (!response.ok) {
throw new Error(`Unable to load bus stops (${response.status})`);
}
const payload = await response.json();
let chunk = [];
if (Array.isArray(payload?.value)) {
chunk = payload.value;
} else if (Array.isArray(payload?.items)) {
chunk = payload.items;
} else if (Array.isArray(payload)) {
chunk = payload;
}
allStops.push(...chunk);
if (chunk.length < pageSize) {
break;
}
skip += pageSize;
if (skip > 15000) {
break;
}
}
return allStops;
}
async function resolveNearestBusStop(position) {
const stops = await loadAllBusStops();
let closest = null;
stops.forEach((stop) => {
const lat = parseCoordinate(stop.Latitude);
const lon = parseCoordinate(stop.Longitude);
if (lat === null || lon === null) {
return;
}
const distanceMeters = haversineMeters(position.lat, position.lon, lat, lon);
if (!closest || distanceMeters < closest.distanceMeters) {
closest = {
code: stop.BusStopCode,
description: stop.Description || "Unnamed stop",
roadName: stop.RoadName || "",
lat,
lon,
distanceMeters
};
}
});
if (!closest) {
throw new Error("No valid bus stop coordinates were returned.");
}
return closest;
}
function ensureMapLayer(leaflet) {
if (mapLayer) {
return;
}
mapLayer = leaflet.tileLayer("https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png", {
maxZoom: 19,
attribution: "&copy; OpenStreetMap contributors"
});
mapLayer.addTo(map);
}
async function updateMap() {
if (!mapContainer) {
return;
}
const leaflet = await import("leaflet");
if (!map) {
map = leaflet.map(mapContainer, {
zoomControl: true,
scrollWheelZoom: true
});
}
ensureMapLayer(leaflet);
if (userLocation) {
const userLatLng = [userLocation.lat, userLocation.lon];
if (!userMarker) {
userMarker = leaflet.marker(userLatLng, { title: "Your location" }).addTo(map);
} else {
userMarker.setLatLng(userLatLng);
}
userMarker.bindPopup("You are here");
}
if (nearestStop) {
const stopLatLng = [nearestStop.lat, nearestStop.lon];
const label = `${nearestStop.description} (${nearestStop.code})`;
if (!stopMarker) {
stopMarker = leaflet.marker(stopLatLng, { title: label }).addTo(map);
} else {
stopMarker.setLatLng(stopLatLng);
}
stopMarker.bindPopup(label);
}
if (userLocation && nearestStop) {
const bounds = leaflet.latLngBounds(
[userLocation.lat, userLocation.lon],
[nearestStop.lat, nearestStop.lon]
);
map.fitBounds(bounds, { padding: [30, 30], maxZoom: 17 });
return;
}
if (userLocation) {
map.setView([userLocation.lat, userLocation.lon], 16);
return;
}
map.setView([1.3521, 103.8198], 11);
}
function getBrowserPosition() {
return new Promise((resolve, reject) => {
if (!navigator.geolocation) {
reject(new Error("Geolocation is not supported in this browser."));
return;
}
navigator.geolocation.getCurrentPosition(resolve, reject, {
enableHighAccuracy: true,
timeout: 10000,
maximumAge: 60000
});
});
}
async function resolveLocationAndNearestStop() {
locating = true;
locationMessage = "Resolving your location...";
try {
const position = await getBrowserPosition();
userLocation = {
lat: position.coords.latitude,
lon: position.coords.longitude
};
locationMessage = "Finding nearest bus stop...";
nearestStop = await resolveNearestBusStop(userLocation);
locationMessage = `Nearest stop: ${nearestStop.description} (${nearestStop.code}), ${Math.round(
nearestStop.distanceMeters
)}m away`;
setStatus("Location Ready", "ok");
await updateMap();
} catch (error) {
const message = error?.message || "Unable to resolve current location.";
locationMessage = message;
setStatus("Location Error", "error");
} finally {
locating = false;
}
}
function seedDefaultParams() {
const selected = getSelectedEndpoint();
@@ -197,14 +397,16 @@
}
async function runArrival() {
if (!busStopCode.trim()) {
responseText = JSON.stringify({ error: "BusStopCode is required for arrival checks." }, null, 2);
setStatus("Missing BusStopCode", "error");
return;
if (!nearestStop) {
await resolveLocationAndNearestStop();
if (!nearestStop) {
responseText = JSON.stringify({ error: "Unable to resolve nearest bus stop." }, null, 2);
return;
}
}
const search = new URLSearchParams();
search.append("BusStopCode", busStopCode.trim());
search.append("BusStopCode", nearestStop.code);
if (serviceNo.trim()) {
search.append("ServiceNo", serviceNo.trim());
}
@@ -222,11 +424,19 @@
selectedEndpointIndex = 0;
method = getSelectedEndpoint().method;
seedDefaultParams();
await updateMap();
await resolveLocationAndNearestStop();
});
</script>
<svelte:head>
<title>Bus SG Control Deck</title>
<link
rel="stylesheet"
href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css"
integrity="sha256-p4NxAoJBhIIN+hmNHrzRCf9tD/miZyoHS5obTRR9BMY="
crossorigin=""
/>
</svelte:head>
<div class="bg-grid"></div>
@@ -294,10 +504,20 @@
<h2>Quick Arrival Check</h2>
<button class="btn-accent" type="button" on:click={runArrival}>Get Arrivals</button>
</div>
<div class="location-head">
<p class="hint">{locationMessage}</p>
<button class="btn-ghost" type="button" on:click={resolveLocationAndNearestStop} disabled={locating}>
{locating ? "Locating..." : "Refresh Location"}
</button>
</div>
<div class="map-panel" bind:this={mapContainer}></div>
<div class="grid-two">
<label>
Bus Stop Code
<input bind:value={busStopCode} placeholder="e.g. 83139" />
Resolved Bus Stop
<input value={nearestStop ? `${nearestStop.code} - ${nearestStop.description}` : "Not resolved"} readonly />
</label>
<label>
Service No (optional)