mirror of
https://github.com/furyhawk/bus_sg_app.git
synced 2026-07-21 10:16:48 +00:00
feat: refactor bus arrival page with improved layout and location handling
This commit is contained in:
+55
-30
@@ -95,10 +95,8 @@ h1 {
|
||||
margin-bottom: 0.85rem;
|
||||
}
|
||||
|
||||
.grid-two {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
gap: 0.8rem;
|
||||
.panel-header.compact {
|
||||
margin-bottom: 0.65rem;
|
||||
}
|
||||
|
||||
label {
|
||||
@@ -151,29 +149,6 @@ button:hover {
|
||||
color: #3a3024;
|
||||
}
|
||||
|
||||
.params-head {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin: 0.9rem 0 0.5rem;
|
||||
}
|
||||
|
||||
.params-list {
|
||||
display: grid;
|
||||
gap: 0.55rem;
|
||||
}
|
||||
|
||||
.param-row {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr auto;
|
||||
gap: 0.55rem;
|
||||
}
|
||||
|
||||
.remove-param {
|
||||
background: #efe0d2;
|
||||
color: #552f19;
|
||||
}
|
||||
|
||||
.hint {
|
||||
color: var(--ink-soft);
|
||||
font-size: 0.86rem;
|
||||
@@ -193,13 +168,43 @@ button:hover {
|
||||
|
||||
.map-panel {
|
||||
width: 100%;
|
||||
height: 280px;
|
||||
height: 360px;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 14px;
|
||||
overflow: hidden;
|
||||
margin-bottom: 0.8rem;
|
||||
}
|
||||
|
||||
.stop-meta {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 0.7rem;
|
||||
}
|
||||
|
||||
.meta-item {
|
||||
background: #fff8ea;
|
||||
border: 1px solid #d9c6a8;
|
||||
border-radius: 12px;
|
||||
padding: 0.65rem 0.75rem;
|
||||
}
|
||||
|
||||
.meta-label {
|
||||
display: block;
|
||||
font-size: 0.75rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
color: #846950;
|
||||
margin-bottom: 0.2rem;
|
||||
}
|
||||
|
||||
.arrivals-actions {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
gap: 0.7rem;
|
||||
align-items: end;
|
||||
margin-bottom: 0.8rem;
|
||||
}
|
||||
|
||||
.status-pill {
|
||||
font-size: 0.8rem;
|
||||
padding: 0.28rem 0.55rem;
|
||||
@@ -237,6 +242,11 @@ button:hover {
|
||||
font-size: 1.15rem;
|
||||
}
|
||||
|
||||
.arrival-card h3 {
|
||||
margin: 0;
|
||||
font-size: 1.15rem;
|
||||
}
|
||||
|
||||
.arrival-line {
|
||||
margin-top: 0.35rem;
|
||||
font-size: 0.9rem;
|
||||
@@ -256,6 +266,17 @@ button:hover {
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.raw-response {
|
||||
margin-top: 0.8rem;
|
||||
}
|
||||
|
||||
.raw-response summary {
|
||||
cursor: pointer;
|
||||
color: #4d4032;
|
||||
font-weight: 500;
|
||||
margin-bottom: 0.5rem;
|
||||
}
|
||||
|
||||
.hidden {
|
||||
display: none;
|
||||
}
|
||||
@@ -278,8 +299,8 @@ button:hover {
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
.grid-two,
|
||||
.param-row {
|
||||
.stop-meta,
|
||||
.arrivals-actions {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
@@ -291,4 +312,8 @@ button:hover {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.map-panel {
|
||||
height: 300px;
|
||||
}
|
||||
}
|
||||
|
||||
+185
-324
@@ -1,43 +1,25 @@
|
||||
<script>
|
||||
import { onMount } from "svelte";
|
||||
import { onDestroy, onMount } from "svelte";
|
||||
|
||||
const fallbackEndpointOptions = [
|
||||
{ label: "Bus Arrivals", path: "/api/v1/bus-arrival", method: "GET" },
|
||||
{ label: "Bus Services", path: "/api/v1/bus-services", method: "GET" },
|
||||
{ label: "Bus Routes", path: "/api/v1/bus-routes", method: "GET" },
|
||||
{ label: "Bus Stops", path: "/api/v1/bus-stops", method: "GET" },
|
||||
{ label: "Planned Bus Routes", path: "/api/v1/planned-bus-routes", method: "GET" },
|
||||
{ label: "Passenger Volume (Bus)", path: "/api/v1/passenger-volume/bus", method: "GET" },
|
||||
{
|
||||
label: "Passenger Volume (OD Bus)",
|
||||
path: "/api/v1/passenger-volume/od-bus",
|
||||
method: "GET"
|
||||
}
|
||||
];
|
||||
|
||||
let endpointOptions = [...fallbackEndpointOptions];
|
||||
let selectedEndpointIndex = 0;
|
||||
let method = "GET";
|
||||
let params = [];
|
||||
let responseText = "No request yet.";
|
||||
let statusText = "Idle";
|
||||
let statusState = "";
|
||||
let responseText = "No request yet.";
|
||||
let services = [];
|
||||
|
||||
let serviceNo = "";
|
||||
let locating = false;
|
||||
let loadingArrivals = false;
|
||||
|
||||
let userLocation = null;
|
||||
let nearestStop = null;
|
||||
let locating = false;
|
||||
let locationMessage = "Location not resolved yet.";
|
||||
let locationMessage = "Allow location access to find your nearest bus stop.";
|
||||
|
||||
let mapContainer;
|
||||
let map;
|
||||
let mapLayer;
|
||||
let userMarker;
|
||||
let stopMarker;
|
||||
|
||||
function getSelectedEndpoint() {
|
||||
return endpointOptions[selectedEndpointIndex] || endpointOptions[0];
|
||||
}
|
||||
let linkLine;
|
||||
|
||||
function setStatus(text, state = "") {
|
||||
statusText = text;
|
||||
@@ -61,55 +43,31 @@
|
||||
return earthRadius * c;
|
||||
}
|
||||
|
||||
function titleizePath(path) {
|
||||
return path
|
||||
.replace("/api/v1/", "")
|
||||
.split("/")
|
||||
.map((part) => part.replace(/-/g, " "))
|
||||
.map((word) => word.charAt(0).toUpperCase() + word.slice(1))
|
||||
.join(" - ");
|
||||
function formatDistance(meters) {
|
||||
if (!Number.isFinite(meters)) {
|
||||
return "n/a";
|
||||
}
|
||||
|
||||
if (meters >= 1000) {
|
||||
return `${(meters / 1000).toFixed(2)} km`;
|
||||
}
|
||||
|
||||
return `${Math.round(meters)} m`;
|
||||
}
|
||||
|
||||
async function loadOpenApiEndpoints() {
|
||||
try {
|
||||
const response = await fetch("/openapi.json");
|
||||
if (!response.ok) {
|
||||
return;
|
||||
}
|
||||
|
||||
const spec = await response.json();
|
||||
const paths = spec && spec.paths ? spec.paths : {};
|
||||
const discovered = [];
|
||||
|
||||
Object.entries(paths).forEach(([path, methods]) => {
|
||||
if (!path.startsWith("/api/v1/")) {
|
||||
return;
|
||||
}
|
||||
|
||||
const getMethod = methods && methods.get;
|
||||
if (!getMethod) {
|
||||
return;
|
||||
}
|
||||
|
||||
const tags = Array.isArray(getMethod.tags) ? getMethod.tags.join(" ").toLowerCase() : "";
|
||||
const include = tags.includes("bus") || tags.includes("passenger") || path.includes("bus");
|
||||
if (!include) {
|
||||
return;
|
||||
}
|
||||
|
||||
discovered.push({
|
||||
label: getMethod.summary || titleizePath(path),
|
||||
path,
|
||||
method: "GET"
|
||||
});
|
||||
});
|
||||
|
||||
if (discovered.length > 0) {
|
||||
endpointOptions = discovered.sort((a, b) => a.path.localeCompare(b.path));
|
||||
}
|
||||
} catch {
|
||||
// Keep fallback endpoint options when OpenAPI loading fails.
|
||||
function minutesUntil(isoValue) {
|
||||
if (!isoValue) {
|
||||
return "n/a";
|
||||
}
|
||||
|
||||
const target = new Date(isoValue);
|
||||
if (Number.isNaN(target.getTime())) {
|
||||
return "n/a";
|
||||
}
|
||||
|
||||
const deltaMs = target.getTime() - Date.now();
|
||||
const mins = Math.max(0, Math.round(deltaMs / 60000));
|
||||
return `${mins} min`;
|
||||
}
|
||||
|
||||
async function loadAllBusStops() {
|
||||
@@ -118,8 +76,8 @@
|
||||
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()}`);
|
||||
const query = new URLSearchParams({ $top: String(pageSize), $skip: String(skip) });
|
||||
const response = await fetch(`/api/v1/bus-stops?${query.toString()}`);
|
||||
if (!response.ok) {
|
||||
throw new Error(`Unable to load bus stops (${response.status})`);
|
||||
}
|
||||
@@ -133,16 +91,14 @@
|
||||
} else if (Array.isArray(payload)) {
|
||||
chunk = payload;
|
||||
}
|
||||
|
||||
allStops.push(...chunk);
|
||||
|
||||
if (chunk.length < pageSize) {
|
||||
if (chunk.length < pageSize || skip > 15000) {
|
||||
break;
|
||||
}
|
||||
|
||||
skip += pageSize;
|
||||
if (skip > 15000) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
return allStops;
|
||||
@@ -179,6 +135,21 @@
|
||||
return closest;
|
||||
}
|
||||
|
||||
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
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function ensureMapLayer(leaflet) {
|
||||
if (mapLayer) {
|
||||
return;
|
||||
@@ -219,20 +190,28 @@
|
||||
|
||||
if (nearestStop) {
|
||||
const stopLatLng = [nearestStop.lat, nearestStop.lon];
|
||||
const label = `${nearestStop.description} (${nearestStop.code})`;
|
||||
const stopLabel = `${nearestStop.description} (${nearestStop.code})`;
|
||||
if (!stopMarker) {
|
||||
stopMarker = leaflet.marker(stopLatLng, { title: label }).addTo(map);
|
||||
stopMarker = leaflet.marker(stopLatLng, { title: stopLabel }).addTo(map);
|
||||
} else {
|
||||
stopMarker.setLatLng(stopLatLng);
|
||||
}
|
||||
stopMarker.bindPopup(label);
|
||||
stopMarker.bindPopup(stopLabel);
|
||||
}
|
||||
|
||||
if (userLocation && nearestStop) {
|
||||
const bounds = leaflet.latLngBounds(
|
||||
const linePoints = [
|
||||
[userLocation.lat, userLocation.lon],
|
||||
[nearestStop.lat, nearestStop.lon]
|
||||
);
|
||||
];
|
||||
|
||||
if (!linkLine) {
|
||||
linkLine = leaflet.polyline(linePoints, { color: "#d25f1f", weight: 3, dashArray: "6 8" }).addTo(map);
|
||||
} else {
|
||||
linkLine.setLatLngs(linePoints);
|
||||
}
|
||||
|
||||
const bounds = leaflet.latLngBounds(linePoints);
|
||||
map.fitBounds(bounds, { padding: [30, 30], maxZoom: 17 });
|
||||
return;
|
||||
}
|
||||
@@ -245,22 +224,64 @@
|
||||
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;
|
||||
}
|
||||
async function callArrivalEndpoint(busStopCode) {
|
||||
const query = new URLSearchParams();
|
||||
query.append("BusStopCode", busStopCode);
|
||||
if (serviceNo.trim()) {
|
||||
query.append("ServiceNo", serviceNo.trim());
|
||||
}
|
||||
|
||||
navigator.geolocation.getCurrentPosition(resolve, reject, {
|
||||
enableHighAccuracy: true,
|
||||
timeout: 10000,
|
||||
maximumAge: 60000
|
||||
});
|
||||
});
|
||||
setStatus("Loading", "");
|
||||
responseText = "Loading...";
|
||||
|
||||
const url = `/api/v1/bus-arrival?${query.toString()}`;
|
||||
const response = await fetch(url);
|
||||
const text = await response.text();
|
||||
|
||||
let payload;
|
||||
try {
|
||||
payload = JSON.parse(text);
|
||||
} catch {
|
||||
payload = { raw: text };
|
||||
}
|
||||
|
||||
responseText = JSON.stringify(payload, null, 2);
|
||||
services = Array.isArray(payload?.Services) ? payload.Services.slice(0, 12) : [];
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Arrival request failed (${response.status})`);
|
||||
}
|
||||
}
|
||||
|
||||
async function resolveLocationAndNearestStop() {
|
||||
async function runArrival() {
|
||||
if (!nearestStop) {
|
||||
await resolveLocationAndNearestStop(false);
|
||||
if (!nearestStop) {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
loadingArrivals = true;
|
||||
try {
|
||||
await callArrivalEndpoint(nearestStop.code);
|
||||
setStatus("Arrivals Loaded", "ok");
|
||||
} catch (error) {
|
||||
setStatus("Arrival Error", "error");
|
||||
responseText = JSON.stringify(
|
||||
{
|
||||
error: "Request failed",
|
||||
message: error?.message || "Unknown error"
|
||||
},
|
||||
null,
|
||||
2
|
||||
);
|
||||
services = [];
|
||||
} finally {
|
||||
loadingArrivals = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function resolveLocationAndNearestStop(loadArrivals = true) {
|
||||
locating = true;
|
||||
locationMessage = "Resolving your location...";
|
||||
|
||||
@@ -273,164 +294,37 @@
|
||||
|
||||
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");
|
||||
locationMessage = `Nearest stop: ${nearestStop.description} (${nearestStop.code})`;
|
||||
|
||||
await updateMap();
|
||||
setStatus("Location Ready", "ok");
|
||||
|
||||
if (loadArrivals) {
|
||||
await runArrival();
|
||||
}
|
||||
} catch (error) {
|
||||
const message = error?.message || "Unable to resolve current location.";
|
||||
locationMessage = message;
|
||||
locationMessage = error?.message || "Unable to resolve current location.";
|
||||
setStatus("Location Error", "error");
|
||||
} finally {
|
||||
locating = false;
|
||||
}
|
||||
}
|
||||
|
||||
function seedDefaultParams() {
|
||||
const selected = getSelectedEndpoint();
|
||||
|
||||
if (selected.path === "/api/v1/bus-arrival") {
|
||||
params = [
|
||||
{ key: "BusStopCode", value: "83139" },
|
||||
{ key: "ServiceNo", value: "" }
|
||||
];
|
||||
return;
|
||||
}
|
||||
|
||||
params = [
|
||||
{ key: "$skip", value: "0" },
|
||||
{ key: "$top", value: "10" }
|
||||
];
|
||||
}
|
||||
|
||||
function addParamRow() {
|
||||
params = [...params, { key: "", value: "" }];
|
||||
}
|
||||
|
||||
function removeParamRow(index) {
|
||||
params = params.filter((_, i) => i !== index);
|
||||
}
|
||||
|
||||
function updateParam(index, field, value) {
|
||||
params = params.map((param, i) => {
|
||||
if (i !== index) {
|
||||
return param;
|
||||
}
|
||||
|
||||
return { ...param, [field]: value };
|
||||
});
|
||||
}
|
||||
|
||||
function collectParams() {
|
||||
const search = new URLSearchParams();
|
||||
|
||||
params.forEach((param) => {
|
||||
const key = param.key.trim();
|
||||
const value = param.value.trim();
|
||||
if (key) {
|
||||
search.append(key, value);
|
||||
}
|
||||
});
|
||||
|
||||
return search;
|
||||
}
|
||||
|
||||
function minutesUntil(isoValue) {
|
||||
if (!isoValue) {
|
||||
return "n/a";
|
||||
}
|
||||
|
||||
const target = new Date(isoValue);
|
||||
if (Number.isNaN(target.getTime())) {
|
||||
return "n/a";
|
||||
}
|
||||
|
||||
const deltaMs = target.getTime() - Date.now();
|
||||
const mins = Math.max(0, Math.round(deltaMs / 60000));
|
||||
return `${mins} min`;
|
||||
}
|
||||
|
||||
async function callEndpoint(path, queryParams) {
|
||||
const query = queryParams.toString();
|
||||
const url = query ? `${path}?${query}` : path;
|
||||
|
||||
setStatus("Loading");
|
||||
responseText = "Loading...";
|
||||
|
||||
try {
|
||||
const response = await fetch(url);
|
||||
const text = await response.text();
|
||||
|
||||
let payload;
|
||||
try {
|
||||
payload = JSON.parse(text);
|
||||
} catch {
|
||||
payload = { raw: text };
|
||||
}
|
||||
|
||||
responseText = JSON.stringify(payload, null, 2);
|
||||
if (response.ok) {
|
||||
setStatus(`OK ${response.status}`, "ok");
|
||||
} else {
|
||||
setStatus(`Error ${response.status}`, "error");
|
||||
}
|
||||
|
||||
services = Array.isArray(payload?.Services) ? payload.Services.slice(0, 12) : [];
|
||||
} catch (error) {
|
||||
responseText = JSON.stringify(
|
||||
{
|
||||
error: "Request failed",
|
||||
message: error?.message || "Unknown error"
|
||||
},
|
||||
null,
|
||||
2
|
||||
);
|
||||
setStatus("Network Error", "error");
|
||||
services = [];
|
||||
}
|
||||
}
|
||||
|
||||
async function runRequest() {
|
||||
const selected = getSelectedEndpoint();
|
||||
await callEndpoint(selected.path, collectParams());
|
||||
}
|
||||
|
||||
async function runArrival() {
|
||||
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", nearestStop.code);
|
||||
if (serviceNo.trim()) {
|
||||
search.append("ServiceNo", serviceNo.trim());
|
||||
}
|
||||
|
||||
await callEndpoint("/api/v1/bus-arrival", search);
|
||||
}
|
||||
|
||||
function onEndpointChange() {
|
||||
method = getSelectedEndpoint().method;
|
||||
seedDefaultParams();
|
||||
}
|
||||
|
||||
onMount(async () => {
|
||||
await loadOpenApiEndpoints();
|
||||
selectedEndpointIndex = 0;
|
||||
method = getSelectedEndpoint().method;
|
||||
seedDefaultParams();
|
||||
await updateMap();
|
||||
await resolveLocationAndNearestStop();
|
||||
});
|
||||
|
||||
onDestroy(() => {
|
||||
if (map) {
|
||||
map.remove();
|
||||
map = null;
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Bus SG Control Deck</title>
|
||||
<title>Bus SG Nearby Arrivals</title>
|
||||
<link
|
||||
rel="stylesheet"
|
||||
href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css"
|
||||
@@ -443,106 +337,73 @@
|
||||
<main class="app-shell">
|
||||
<header class="hero">
|
||||
<p class="kicker">Singapore Transit</p>
|
||||
<h1>Bus Control Deck</h1>
|
||||
<p class="subtitle">
|
||||
Live explorer for the bus gateway API at
|
||||
<span>localhost:8067</span>.
|
||||
</p>
|
||||
<h1>Nearby Bus Arrivals</h1>
|
||||
<p class="subtitle">Map-driven view using your current location to detect the nearest bus stop.</p>
|
||||
</header>
|
||||
|
||||
<section class="panel query-panel">
|
||||
<div class="panel-header">
|
||||
<h2>Endpoint Explorer</h2>
|
||||
<button class="btn-accent" type="button" on:click={runRequest}>Run Request</button>
|
||||
</div>
|
||||
|
||||
<div class="grid-two">
|
||||
<label>
|
||||
Endpoint
|
||||
<select bind:value={selectedEndpointIndex} on:change={onEndpointChange}>
|
||||
{#each endpointOptions as item, index}
|
||||
<option value={index}>{item.label} ({item.path})</option>
|
||||
{/each}
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
Request Method
|
||||
<input value={method} readonly />
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="params-head">
|
||||
<h3>Query Params</h3>
|
||||
<button class="btn-ghost" type="button" on:click={addParamRow}>Add Param</button>
|
||||
</div>
|
||||
|
||||
<div class="params-list">
|
||||
{#each params as param, index}
|
||||
<div class="param-row">
|
||||
<input
|
||||
class="param-key"
|
||||
placeholder="Key"
|
||||
value={param.key}
|
||||
on:input={(event) => updateParam(index, "key", event.currentTarget.value)}
|
||||
/>
|
||||
<input
|
||||
class="param-value"
|
||||
placeholder="Value"
|
||||
value={param.value}
|
||||
on:input={(event) => updateParam(index, "value", event.currentTarget.value)}
|
||||
/>
|
||||
<button class="remove-param" type="button" on:click={() => removeParamRow(index)}>
|
||||
Remove
|
||||
</button>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="panel quick-panel">
|
||||
<div class="panel-header">
|
||||
<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"}
|
||||
<section class="panel">
|
||||
<div class="panel-header compact">
|
||||
<div>
|
||||
<h2>Live Map</h2>
|
||||
<p class="hint">{locationMessage}</p>
|
||||
</div>
|
||||
<button class="btn-ghost" type="button" on:click={() => resolveLocationAndNearestStop()} disabled={locating}>
|
||||
{locating ? "Locating..." : "Use Current Location"}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="map-panel" bind:this={mapContainer}></div>
|
||||
|
||||
<div class="grid-two">
|
||||
<label>
|
||||
Resolved Bus Stop
|
||||
<input value={nearestStop ? `${nearestStop.code} - ${nearestStop.description}` : "Not resolved"} readonly />
|
||||
</label>
|
||||
<label>
|
||||
Service No (optional)
|
||||
<input bind:value={serviceNo} placeholder="e.g. 14" />
|
||||
</label>
|
||||
<div class="stop-meta">
|
||||
<div class="meta-item">
|
||||
<span class="meta-label">Nearest stop</span>
|
||||
<strong>{nearestStop ? `${nearestStop.code} - ${nearestStop.description}` : "Not resolved"}</strong>
|
||||
</div>
|
||||
<div class="meta-item">
|
||||
<span class="meta-label">Road</span>
|
||||
<strong>{nearestStop?.roadName || "-"}</strong>
|
||||
</div>
|
||||
<div class="meta-item">
|
||||
<span class="meta-label">Distance</span>
|
||||
<strong>{nearestStop ? formatDistance(nearestStop.distanceMeters) : "-"}</strong>
|
||||
</div>
|
||||
</div>
|
||||
<p class="hint">Uses /api/v1/bus-arrival with DataMall-style query parameters.</p>
|
||||
</section>
|
||||
|
||||
<section class="panel">
|
||||
<div class="panel-header">
|
||||
<h2>Response</h2>
|
||||
<div class="panel-header compact">
|
||||
<h2>Arrivals</h2>
|
||||
<span class={`status-pill ${statusState}`}>{statusText}</span>
|
||||
</div>
|
||||
|
||||
<div class={`arrival-board ${services.length > 0 ? "" : "hidden"}`}>
|
||||
{#each services as service}
|
||||
<article class="arrival-card">
|
||||
<h4>Service {service.ServiceNo || "?"}</h4>
|
||||
<p class="arrival-line">Next: {minutesUntil(service?.NextBus?.EstimatedArrival)}</p>
|
||||
<p class="arrival-line">Following: {minutesUntil(service?.NextBus2?.EstimatedArrival)}</p>
|
||||
</article>
|
||||
{/each}
|
||||
<div class="arrivals-actions">
|
||||
<label>
|
||||
Service filter (optional)
|
||||
<input bind:value={serviceNo} placeholder="e.g. 14" />
|
||||
</label>
|
||||
<button class="btn-accent" type="button" on:click={runArrival} disabled={loadingArrivals || locating}>
|
||||
{loadingArrivals ? "Loading..." : "Refresh Arrivals"}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<pre class="response">{responseText}</pre>
|
||||
{#if services.length > 0}
|
||||
<div class="arrival-board">
|
||||
{#each services as service}
|
||||
<article class="arrival-card">
|
||||
<h3>Service {service.ServiceNo || "?"}</h3>
|
||||
<p class="arrival-line">Next: {minutesUntil(service?.NextBus?.EstimatedArrival)}</p>
|
||||
<p class="arrival-line">Following: {minutesUntil(service?.NextBus2?.EstimatedArrival)}</p>
|
||||
<p class="arrival-line">Third: {minutesUntil(service?.NextBus3?.EstimatedArrival)}</p>
|
||||
</article>
|
||||
{/each}
|
||||
</div>
|
||||
{:else}
|
||||
<p class="hint">No arrivals loaded yet. Tap "Use Current Location" then "Refresh Arrivals".</p>
|
||||
{/if}
|
||||
|
||||
<details class="raw-response">
|
||||
<summary>Raw response</summary>
|
||||
<pre class="response">{responseText}</pre>
|
||||
</details>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
Reference in New Issue
Block a user