How bus.html and server.js work together

This page explains the logic of the frontend map page and the backend Node server as one system. The goal is not just to describe what each line does, but to explain the scientific and technical ideas underneath: client-server architecture, caching, fallback logic, GTFS time handling, realtime enrichment, and the role of GeoServer and PostgreSQL.

Frontend: Leaflet map Backend: Node + Express Data store: PostgreSQL Spatial service: GeoServer WFS Live feed: NTA GTFS-Realtime

1. Overall architecture

The system is split into a presentation layer, an application layer, and a data layer. bus.html is the presentation layer: it renders the map, sends requests, and turns returned data into markers and popups. server.js is the application layer: it fetches live feeds, joins them to static timetable data, and exposes simplified endpoints for the frontend. PostgreSQL stores the GTFS static tables, and GeoServer publishes the static stops as a WFS layer.

Flow chart placeholder: overall system architecture
Insert a flow chart here showing: Browser → bus.html → server.js → NTA realtime API / PostgreSQL / GeoServer → response back to browser.

Frontend

Displays the map, user position, stop icons, vehicle markers, and popup tables.

Backend

Protects the API key, performs joins, caches feeds, and returns clean JSON.

GeoServer

Publishes stop geometry as WFS so the browser can load static stop locations.

PostgreSQL

Stores routes, trips, stop times, calendars, and calendar exceptions for schedule logic.

2. The role of bus.html

The frontend page is responsible for drawing the user-facing map and reacting to events. It does not try to solve timetable logic itself. Instead, it delegates difficult operations to the backend. This separation is good engineering practice because the browser should focus on rendering and interaction, while the server handles protected credentials, database queries, and fusion of multiple data sources. The uploaded bus.html shows this clearly: static stops come from GeoServer WFS, while live vehicle and arrival information come from custom backend endpoints. fileciteturn1file0

A key design choice is that the browser never calls the NTA realtime API directly. That means no API key is exposed and no browser-side CORS problem has to be solved.

3. The role of server.js

The backend is effectively a translator and coordinator. It takes raw GTFS-Realtime JSON from the transport API, combines it with GTFS static data in PostgreSQL, applies time conversions and fallback logic, then returns concise JSON designed for the Leaflet frontend. It is not merely forwarding data. It is interpreting it. That is why the code contains caches, helper functions, SQL lookups, and ranking logic for live versus estimated versus scheduled arrivals.

Scientifically, this is a data integration pipeline. Several datasets with different temporal behaviour are fused into one coherent response model for the user.

4. Full request flow

  1. The browser loads bus.html and initialises Leaflet.
  2. The page requests stop geometry from GeoServer WFS.
  3. The page requests enriched vehicle data from /vehicles-display.
  4. When the user clicks a stop, the page requests /stop-arrivals?stop_id=....
  5. server.js uses cached GTFS-Realtime feeds plus PostgreSQL schedule lookups to build a response.
  6. The browser renders that response as a table in a popup.
Flow chart placeholder: request flow for vehicles and stop arrivals
Insert a flow chart here showing two paths: vehicle loading and stop-click arrival loading.

5. bus.html explained in logical order

5.1 HTML and CSS structure

<div id="map"></div>
<div class="status-box" id="statusBox">Loading map...</div>

The page has two main visible elements: the map container and a status box. The map fills the full viewport. The status box is an overlay positioned above the map, which is useful because it allows operational messages such as loading state or error state to remain visible without interfering with the geographic canvas.

5.2 Leaflet map setup

const map = L.map('map').setView([53.19508656547266, -6.118442233671725], 15);

L.tileLayer("https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png", {
  attribution: "&copy; OpenStreetMap contributors"
}).addTo(map);

This creates the Leaflet map and adds an OpenStreetMap basemap. The coordinates and zoom level give the initial map state. This is important because a web map needs a deterministic initial extent. Without one, the user would see a blank or arbitrary view. The basemap is purely visual context; it is not where the project-specific transport data comes from.

5.3 Layer groups

const vehiclesLayer = L.layerGroup().addTo(map);
const stopsLayer = L.layerGroup().addTo(map);

These groups separate dynamic vehicles from static stops. This is a small implementation detail, but it has a large conceptual benefit: data types are isolated by function. That means they can be refreshed or cleared independently. Vehicles refresh frequently. Stops usually do not.

5.4 Geolocation

function locateUser() {
  map.locate({
    setView: false,
    enableHighAccuracy: true
  });
}

The map uses browser geolocation to find the user, but deliberately avoids automatically letting Leaflet control the final zoom. Instead, the code listens for the locationfound event and then applies its own stable zoom level. That is a good example of controlling library defaults rather than accepting a result that may feel inconsistent.

5.5 Stop icon logic

function getStopIcon(props) {
  const mode = (props.mode || "").toLowerCase();

  if (mode === "luas") return luasStopIcon;
  if (mode === "rail") return trainStopIcon;
  return busStopIcon;
}

This function classifies stops by a transport mode attribute coming from GeoServer. This is an example of cartographic encoding: a semantic attribute is turned into a visual symbol. It is a small function, but it expresses a core web mapping principle: attributes drive representation.

5.6 Loading stops from GeoServer WFS

const url =
  "https://webgis-bc.sytes.net/geoserver/webgis_project/ows?" +
  "service=WFS&version=1.0.0&request=GetFeature" +
  "&typeName=webgis_project:gtfs_stops" +
  "&outputFormat=application/json";

This call asks GeoServer for vector stop data in GeoJSON form. GeoServer is used here because the stops are static spatial features already stored and published in the geospatial stack. The browser therefore consumes them directly as map features. The browser does not need SQL for this step because GeoServer handles the spatial publication role. The stop loading code in the uploaded file shows this exact WFS pattern. fileciteturn1file0

5.7 Clicking a stop triggers backend logic

const res = await fetch(
  `https://webgis-bc.sytes.net/stop-arrivals?stop_id=${encodeURIComponent(stopId)}`
);

This is one of the most important lines in the whole system. The stop location itself came from GeoServer, but the arrivals do not. Arrivals require live feed access, trip joins, timetable lookups, and fallback logic, so they are delegated to the backend. The browser simply says, in effect, “here is the stop ID; give me the best arrival information you can produce.”

5.8 Popup table rendering

The popup table is a presentation-layer summary of a much more complicated backend decision process. Columns such as route, scheduled time, actual/predicted time, delay, due time, source, and status are not raw feed fields copied directly into a table. They are the final product of data enrichment.

Flow chart placeholder: popup data assembly
Insert a flow chart here showing how popup fields come from live feed, static GTFS tables, and formatting helpers.

5.9 Loading live vehicles

const res = await fetch("https://webgis-bc.sytes.net/vehicles-display");

Again, the frontend requests an already-enriched endpoint, not the raw GTFS-Realtime Vehicles feed. That means the frontend stays simple. It only needs to place markers and display fields. Complex interpretation is centralised on the server, which is easier to maintain and debug.

5.10 Refresh cycle

setInterval(loadVehicles, 20000);

The page refreshes vehicles every 20 seconds. This is a user-interface refresh interval, not the upstream API refresh interval. That distinction matters. The backend separately caches upstream feeds, so frequent browser refreshes do not necessarily translate into frequent API calls to the transport provider.

6. server.js explained in logical order

6.1 Module imports and server creation

const express = require("express");
const { Pool } = require("pg");

const app = express();
const PORT = 3001;

express creates HTTP endpoints. pg provides PostgreSQL access. This is the minimal infrastructure required for an API server that must both expose routes and run SQL.

6.2 Configuration

const VEHICLES_URL = "https://api.nationaltransport.ie/gtfsr/v2/Vehicles?format=json";
const TRIP_UPDATES_URL = "https://api.nationaltransport.ie/gtfsr/v2/TripUpdates?format=json";

These constants define the upstream realtime sources. The design choice here is explicit: the server knows where live feeds come from, while the frontend only knows about local endpoints. This decouples the frontend from the upstream provider and makes the system easier to refactor later.

6.3 PostgreSQL connection pool

const pool = new Pool({
  host: "webgis-bc.sytes.net",
  port: 5432,
  database: "webgis_project_db",
  user: "postgres",
  password: "postgres-bc"
});

A pool is used rather than opening a brand new connection for every query. Scientifically, a connection pool is a resource-management mechanism. It reduces overhead and improves throughput under repeated access. Since the application repeatedly looks up routes, trips, and schedule rows, pooling is the correct pattern.

6.4 In-memory caches

let vehiclesCache = null;
let tripUpdatesCache = null;

const routeCache = new Map();
const tripCache = new Map();

There are two different cache types here. The first cache stores entire upstream realtime feeds. The second stores repeated database lookup results. These solve different performance problems. Feed caches reduce calls to the transport API. Route and trip caches reduce repeated SQL lookups for identifiers that appear many times in live data.

6.5 Time helper functions

The time helper section is longer than it might first appear necessary, but it is essential because GTFS time is not normal wall-clock time. GTFS allows times above 24:00:00 to represent services after midnight that still belong to the previous service day. This is why helpers such as secsToDisplayHHMM, gtfsTimeToDate, and yesterday/today service-day functions exist.

function secsToDisplayHHMM(totalSecs) {
  const wrapped = ((totalSecs % 86400) + 86400) % 86400;
  const hours = Math.floor(wrapped / 3600);
  const mins = Math.floor((wrapped % 3600) / 60);
  return `${String(hours).padStart(2, "0")}:${String(mins).padStart(2, "0")}`;
}

This wraps extended GTFS time back into a human clock display. For example, 26:30 becomes 02:30. That is not just formatting; it is a translation between two temporal models: the service-day model used by GTFS and the civil time model used by passengers.

Flow chart placeholder: GTFS time rollover logic
Insert a flow chart here showing how 24+:00 GTFS times belong to the previous service day but are displayed as normal times to the user.

6.6 Fetching upstream realtime feeds

async function fetchJson(url) {
  const res = await fetch(url, {
    headers: { "x-api-key": API_KEY }
  });
  ...
}

The generic fetch helper centralises upstream access and error handling. It also ensures the API key stays server-side. This is one of the clearest examples of why an application server exists at all. Without it, the frontend would need to expose secrets or depend on looser upstream CORS policy.

6.7 Static GTFS lookup helpers

Functions such as getTripInfo, getRouteInfo, and the stop-time lookup helpers are responsible for enriching live identifiers with schedule and route context. A live feed may tell you a trip ID or a stop sequence, but that is not enough for a user-facing popup. The server therefore joins those identifiers back to the GTFS static tables.

6.8 Realtime lookup helpers

The code builds maps such as trip ID → trip update and trip ID → best delay. These are indexing operations performed in memory. Conceptually, this transforms a large JSON feed from a sequential structure into a lookup structure. That reduces repeated scanning cost when many vehicles or many stop arrival rows need to be enriched.

6.9 Scheduled and estimated fallback helpers

The fallback logic is where the code becomes most interesting. If the clicked stop has direct live stop updates, those are used. If it does not, the server looks up scheduled rows near the current time and then tries to estimate them using known trip-level delays from elsewhere in the live feed. If even that is not possible, pure scheduled rows are returned.

This is a graded evidence system. Direct live stop data is highest confidence. Estimated timetable rows are lower confidence. Pure scheduled rows are lowest confidence, but still better than silence.

6.10 Endpoint design

The server exposes both production endpoints and debug endpoints. That is good practice. Production endpoints return concise, user-facing data models. Debug endpoints reveal intermediate evidence and make it easier to inspect the pipeline.

7. SQL calls in detail

The SQL in this project is not just “read some rows.” It is where the backend ties live events to the static timetable model. Each query solves a specific problem.

7.1 Trip lookup

SELECT
  trip_id,
  route_id,
  trip_headsign
FROM transport.gtfs_trips
WHERE trip_id = $1
LIMIT 1

This query uses the trip ID from GTFS-Realtime to retrieve the associated route ID and trip headsign from the static GTFS table. A live vehicle often contains only identifiers. To produce a usable route number and destination, the backend must dereference those identifiers through static tables.

Why $1?

It is a parameter placeholder, which prevents SQL injection and lets the database prepare the statement safely.

Why LIMIT 1?

The query expects a unique match. It also stops scanning once the first match is found.

7.2 Route lookup

SELECT
  route_id,
  route_short_name,
  route_long_name
FROM transport.gtfs_routes
WHERE route_id = $1
LIMIT 1

This turns a route identifier into a human-readable label. The short route name is usually what the passenger recognises. The long route name can be used as fallback explanatory text.

7.3 Stop time lookup by sequence

SELECT
  trip_id,
  stop_id,
  stop_sequence,
  arrival_time,
  departure_time,
  arrival_secs,
  departure_secs,
  stop_headsign
FROM transport.gtfs_stop_times
WHERE trip_id = $1
  AND stop_sequence = $2
LIMIT 1

This is the best schedule lookup because stop sequence is very precise within a trip. If the live update says a vehicle is at stop sequence 17, the server can retrieve the corresponding scheduled arrival/departure row exactly. This makes it possible to compare scheduled versus actual or estimated timing.

7.4 Stop time fallback by stop ID

SELECT
  trip_id,
  stop_id,
  stop_sequence,
  arrival_time,
  departure_time,
  arrival_secs,
  departure_secs,
  stop_headsign
FROM transport.gtfs_stop_times
WHERE trip_id = $1
  AND stop_id = $2
ORDER BY stop_sequence
LIMIT 1

This is a fallback in case the exact stop sequence is unavailable. Ordering by sequence means the earliest occurrence of that stop in the trip is chosen. That matters because a stop ID could theoretically appear more than once on a circular or unusual trip pattern.

7.5 Scheduled fallback arrivals query

This is the most sophisticated SQL block in the file. It uses several common table expressions to determine which services are active, accounting for both normal calendar rules and calendar exceptions. It also distinguishes between today’s services and yesterday’s rollover services after midnight.

WITH service_base_today AS (...),
service_added_today AS (...),
service_removed_today AS (...),
active_services_today AS (...),
service_base_yesterday AS (...),
service_added_yesterday AS (...),
service_removed_yesterday AS (...),
active_services_yesterday AS (...),
today_rows AS (...),
yesterday_rollover_rows AS (...)
SELECT *
FROM (
  SELECT * FROM today_rows
  UNION ALL
  SELECT * FROM yesterday_rollover_rows
) q
ORDER BY COALESCE(arrival_secs, departure_secs)
LIMIT ...

What each stage is doing

CTE Purpose Why it matters
service_base_today Finds regular services active today using gtfs_calendar and the weekday column. Establishes the default set of services that should run today.
service_added_today Finds exception-based additions from gtfs_calendar_dates. Captures special services not covered by the weekly calendar.
service_removed_today Finds exception-based removals. Prevents services from being shown when they are cancelled for that specific date.
active_services_today Combines regular and added services. Produces the candidate service set for today.
service_base_yesterday etc. Repeats the same logic for the previous service day. Allows 24+, 25+, 26+ GTFS times to be found after midnight.
today_rows Joins stop times, trips, routes, and active services for current-day times. Builds the actual candidate arrival rows for the stop.
yesterday_rollover_rows Finds previous-day rows in the extended time window. Prevents after-midnight services from disappearing incorrectly.

The scientific importance of this query is that it explicitly models the transport timetable as a service-day problem rather than a naive clock-time problem. That is necessary for correctness in transit systems.

Flow chart placeholder: SQL CTE schedule logic
Insert a flow chart here showing the CTE pipeline from calendar tables to active services to stop-time rows.

8. Fallback logic

Fallback is one of the central ideas in the project. A realtime system is never guaranteed to have perfect evidence for every stop and every vehicle. A robust system therefore needs a hierarchy of substitutes.

8.1 Fallback hierarchy for stop arrivals

  1. Live: there is a direct stop-time update for the clicked stop.
  2. Estimated: there is no direct stop update, but there is a trip-level delay that can be applied to the scheduled stop row.
  3. Scheduled: there is no usable live evidence, so the timetable alone is returned.
const combinedArrivals = [...arrivals, ...extraFallback]
  .sort((a, b) => {
    ...
    const rank = { live: 0, estimated: 1, scheduled: 2 };
    return (rank[a.source] ?? 9) - (rank[b.source] ?? 9);
  })
  .slice(0, 10);

This ranking logic formalises the confidence order. It ensures the most evidentially direct information wins if two rows are otherwise equally imminent.

8.2 Fallback for missing schedule match

If a live stop update exists but the matching static stop-time row cannot be found, the code can still keep the arrival if an absolute realtime timestamp exists. That is a subtle but important design decision. It avoids throwing away useful live evidence merely because the static join was incomplete.

8.3 Fallback for empty and unavailable data

400

Used when required input like stop_id is missing.

503

Used when caches have not been populated yet and live data is not ready.

500

Used for unexpected backend failures such as thrown exceptions.

Flow chart placeholder: fallback decision tree
Insert a decision tree here for live → estimated → scheduled fallback selection.

9. Overall functionality of GeoServer and PostgreSQL in this system

9.1 Why GeoServer is used

GeoServer serves the static stop layer over WFS. That is appropriate because a stop is fundamentally a spatial feature: it has geometry and attributes, and it benefits from being published as a standard geospatial service. GeoServer is therefore acting as the spatial publication component of the stack.

9.2 Why PostgreSQL is used

PostgreSQL stores the GTFS static relational model: routes, trips, stop times, calendars, and calendar-date exceptions. These are tables with strong interrelationships. SQL is the correct tool for querying this structure because the problem is relational and time-based. The backend repeatedly asks questions like: “Which route is linked to this trip?” and “Which timetable rows at this stop are active around now?”

9.3 Why not do everything in GeoServer or everything in the browser?

GeoServer is excellent for serving geospatial layers, but the arrival logic here is not just a map service problem. It is a data integration and decision problem. The browser, meanwhile, is a poor place to keep secrets, issue repeated SQL, or carry out complicated merge logic. Therefore the split is sensible: GeoServer for static spatial publication, PostgreSQL for structured schedule storage, and Node for integration and API composition.

10. Small ideas and big concepts

10.1 Small implementation ideas

10.2 Big architectural concepts

10.3 Final summary

Viewed as a whole, the system is a layered web GIS and transport-information application. The browser renders the map and sends concise requests. GeoServer publishes static stop features. PostgreSQL holds the timetable model. The Node backend fuses realtime feeds with static data, handles service-day time complexity, applies fallback rules, and emits a response model simple enough for Leaflet popups and markers. The result is a system that is both user-friendly and technically robust.