diff --git a/.gitignore b/.gitignore index 76b6e9f..793e6e4 100644 --- a/.gitignore +++ b/.gitignore @@ -3,3 +3,4 @@ tegola-cache/ web-assets/ .vscode *.code-workspace +.idea/ diff --git a/web/public/icons/haltestelle.svg b/web/public/icons/haltestelle.svg new file mode 100644 index 0000000..8916a0b --- /dev/null +++ b/web/public/icons/haltestelle.svg @@ -0,0 +1,29 @@ + +image/svg+xml \ No newline at end of file diff --git a/web/src/icons/bus.svg b/web/src/icons/bus.svg index 5b6fb6d..129df8e 100644 --- a/web/src/icons/bus.svg +++ b/web/src/icons/bus.svg @@ -1,95 +1,8 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file + + + + + + diff --git a/web/src/index.ts b/web/src/index.ts index 61def2a..d9ebe5e 100644 --- a/web/src/index.ts +++ b/web/src/index.ts @@ -10,6 +10,7 @@ import ContextMenu from './contextmenu' import VillagesEditor from './villages' import { roundPosition, fetchWithTimeout } from './util' import InstallControl from './installcontrol' +import TransitInfo from './transit' import ExportControl from './export/export' import { manifest } from 'virtual:render-svg' @@ -90,6 +91,7 @@ class EventMap { layer_switcher?: LayerSwitcher url_hash?: URLHash marker?: Marker + transit_info?: TransitInfo init() { registerSW({ immediate: true }) @@ -179,6 +181,8 @@ class EventMap { this.map.on('styledata', () => { updateVehicles(this.map!) }) + + this.transit_info = new TransitInfo(this.map); } } diff --git a/web/src/transit.css b/web/src/transit.css new file mode 100644 index 0000000..49eda5f --- /dev/null +++ b/web/src/transit.css @@ -0,0 +1,116 @@ +.marker-stop { + font-family: "Raleway", sans-serif; + font-size: 12px; + + display: flex; + flex-direction: column; + align-items: center; + + -webkit-text-stroke: 5px rgb(242, 243, 240); + color: #000; + paint-order: stroke fill; +} + +.marker-stop::before { + content: ""; + display: block; + background-color: #f0c900; + background-image: url("/icons/haltestelle.svg"); + background-size: cover; + width: 25px; + height: 25px; + border-radius: 50%; +} + +.marker-vehicle { + display: block; + width: 50px; + height: 25px; + background-image: url("/icons/bus.svg"); + background-repeat: no-repeat; + background-position: 50% 50%; + background-size: contain +} + +.popup-vehicle .vehicle-name { + font-weight: bold; + font-size: 1.3em; +} + +.popup-vehicle .vehicle-plate { + background: #ffd307; + font-family: monospace; + font-size: 1em; + padding: 2px 5px; + border-radius: 5px; + font-weight: bold; + margin: 7px auto; + width: fit-content; +} + +.popup-vehicle .vehicle-stop { + font-size: 1.2em; +} + +.popup-vehicle .vehicle-route span { + border-radius: 7px; + padding: 2px 5px; +} + +.departure-board { + display: grid; + grid-template-columns: auto 1fr auto; +} + +.departure-board .board-line { + display: grid; + grid-template-columns: subgrid; + grid-column: 1 / 4; + grid-gap: 2px 10px; + border-bottom: 1px solid #eee; + padding-bottom: 3px; + margin-bottom: 3px; +} + +.departure-board .board-line:last-of-type { + border-bottom: none; + margin-bottom: 0; + padding-bottom: 0; +} + +.departure-board .time { + grid-column: 1; + font-weight: bold; + font-size: 1.3em; +} + +.departure-board .time.on-time { + color: #528329; +} + +.departure-board .time.slightly-late { + color: #F9E200; +} + +.departure-board .time.late { + color: #F77F02; +} + +.departure-board .time.cancelled { + color: red; + text-decoration: line-through; +} + +.departure-board .destination { + grid-column: 2; + font-size: 1.2em; +} + +.departure-board .platform { + grid-column: 3; + font-size: 1.5em; +} + +.departure-board .operator, .departure-board .note { + grid-column: 1 / 4; +} \ No newline at end of file diff --git a/web/src/transit.ts b/web/src/transit.ts new file mode 100644 index 0000000..86432a7 --- /dev/null +++ b/web/src/transit.ts @@ -0,0 +1,367 @@ +import './transit.css'; +import maplibregl from "maplibre-gl"; + +const STOP_ZOOM_LEVEL = 15; +const VEHICLE_ZOOM_LEVEL = 12; + +const GTFS_FEED = "https://tracking.tfemf.uk/media/gtfs.json"; +const GTFS_RT_FEED = "https://tracking.tfemf.uk/media/gtfs-rt.json"; +const DEPARTURE_BOARD = "https://tracking.tfemf.uk/hafas/departureBoard"; + +type Stop = { + name: string; + marker: maplibregl.Marker; + popup: maplibregl.Popup; +} + +type Stops = { + [stop_id: string]: Stop; +}; + +type Vehicle = { + marker: maplibregl.Marker; + popup: maplibregl.Popup; +} + +type Vehicles = { + [vehicle_id: string]: Vehicle; +}; + +type GTFSFeed = { + stops: { + [stop_id: string]: { + stop_id: string; + stop_code: string; + stop_name: string; + tts_stop_name?: string; + stop_desc?: string; + stop_lat: number; + stop_lon: number; + stop_url?: string; + }; + }; + routes: { + [route_id: string]: { + route_id: string; + agency_id: string; + route_short_name: string; + route_long_name: string; + route_desc: string; + route_type: number; + route_url: string; + route_color: string; + route_text_color: string; + route_sort_order: number; + } + }; + trips: { + [trip_id: string]: { + route_id: string; + service_id: string; + trip_id: string; + trip_headsign: string; + trip_short_name: string; + direction_id: number; + block_id: string; + shape_id: string; + } + } +}; + +type GTFSRTFeed = { + vehiclePositions: [{ + id: string; + vehicle: { + id: string; + label: string; + licensePlate?: string; + }; + trip?: { + trip_id: string; + routeId?: string; + scheduleRelationship: string; + }; + position: { + latitude: number; + longitude: number; + }; + stopId?: string; + currentStopSequence?: string; + currentStatus?: string; + timestamp: number; + }] +}; + +type HAFASDepartureBoard = { + requestId: string; + Departure: [{ + Product: [{ + operatorInfo: { + name: string; + id: string; + }, + catOut: string; + }], + name: string; + direction: string; + time: string; + date: string; + rtTime: string; + rtDate: string; + rtPlatform?: { + text: string; + hidden: boolean; + }; + Notes: { + Note: [{ + value: string; + }] + }; + cancelled: boolean; + }] +}; + +export default class TransitInfo { + _map: maplibregl.Map; + + stops: Stops; + vehicles: Vehicles; + routes: GTFSFeed['routes']; + trips: GTFSFeed['trips']; + + constructor(map: maplibregl.Map) { + this._map = map; + + this.stops = {}; + this.vehicles = {}; + this.routes = {}; + this.trips = {}; + + this.loadGTFSFeed(); + this.updateGTFSRTFeed(); + + this._map.on('zoom', this.updateStopVisibility.bind(this)); + } + + loadGTFSFeed() { + fetch(GTFS_FEED) + .then(response => response.json()) + .then((data: GTFSFeed) => { + this.routes = data.routes; + this.trips = data.trips; + this.loadStops(data.stops); + this.updateStopVisibility(); + }) + .catch(error => { + console.error("Failed to load GTFS feed", error); + }); + } + + updateGTFSRTFeed() { + fetch(GTFS_RT_FEED) + .then(response => response.json()) + .then((data: GTFSRTFeed) => { + this.updateVehicles(data.vehiclePositions); + this.updateStopVisibility(); + }) + .catch(error => { + console.error("Failed to load GTFS-RT feed", error); + }); + setTimeout(this.updateGTFSRTFeed.bind(this), 2500); + } + + loadStops(stops: GTFSFeed['stops']) { + this.clearStops(); + + this.stops = {}; + Object.values(stops).forEach((stop) => { + const el = document.createElement('div'); + el.className = 'marker-stop'; + el.innerText = stop.stop_name; + + const popup = new maplibregl.Popup({ + className: 'popup-stop', + offset: 25 + }) + .setMaxWidth("80vw") + .on('open', () => { + this.stopPopupOpen(stop.stop_id); + }); + + const marker = new maplibregl.Marker({ + element: el + }) + .setLngLat([stop.stop_lon, stop.stop_lat]) + .setPopup(popup) + .addTo(this._map); + this.stops[stop.stop_id] = { + name: stop.stop_name, + marker: marker, + popup: popup + }; + }); + } + + updateVehicles(vehicles: GTFSRTFeed['vehiclePositions']) { + const seenVehicles: string[] = []; + vehicles.forEach((vehicle) => { + seenVehicles.push(vehicle.id); + + if (vehicle.id in this.vehicles) { + this.vehicles[vehicle.id].marker + .setLngLat([vehicle.position.longitude, vehicle.position.latitude]); + this.vehicles[vehicle.id].popup + .setHTML(this.makeVehiclePopup(vehicle)); + } else { + const el = document.createElement('div'); + el.className = 'marker-vehicle'; + + const popup = new maplibregl.Popup({ + className: 'popup-vehicle', + offset: 25 + }) + .setHTML(this.makeVehiclePopup(vehicle)); + + const marker = new maplibregl.Marker({ + element: el + }) + .setLngLat([vehicle.position.longitude, vehicle.position.latitude]) + .setPopup(popup) + .addTo(this._map); + + this.vehicles[vehicle.id] = { + marker: marker, + popup: popup, + } + } + }); + + Object.entries(this.vehicles).forEach(([vehicle_id, vehicle]) => { + if (!seenVehicles.includes(vehicle_id)) { + vehicle.marker.remove(); + delete this.vehicles[vehicle_id]; + } + }) + } + + makeVehiclePopup(vehicle: GTFSRTFeed['vehiclePositions'][0]) { + const updated = new Date(vehicle.timestamp * 1000); + + let html = ` +
${vehicle.vehicle.label}
+
${vehicle.vehicle.licensePlate}
+`; + + if (vehicle.stopId && vehicle.stopId in this.stops) { + let verb; + if (vehicle.currentStatus == "IN_TRANSIT_TO") { + verb = "Next stop: "; + } else if (vehicle.currentStatus == "STOPPED_AT") { + verb = "Currently at: "; + } else if (vehicle.currentStatus == "INCOMING_AT") { + verb = "Arriving at: "; + } + html += `
${verb}${this.stops[vehicle.stopId].name}
`; + } + + if (vehicle.trip && vehicle.trip.routeId && vehicle.trip.routeId in this.routes) { + html += `
Route ` + + `` + + `${this.routes[vehicle.trip.routeId].route_short_name}` + + `
`; + } + + html += `
Last updated ${updated.toLocaleTimeString()}
`; + + return html; + } + + clearStops() { + Object.values(this.stops).forEach((stop) => { + stop.marker.remove(); + }); + } + + updateStopVisibility() { + const zoom = this._map.getZoom(); + Object.values(this.stops).forEach((stop) => { + if (zoom < STOP_ZOOM_LEVEL) { + stop.marker.getElement().style.visibility = 'hidden'; + } else { + stop.marker.getElement().style.visibility = 'visible'; + } + }); + Object.values(this.vehicles).forEach((vehicle) => { + if (zoom < VEHICLE_ZOOM_LEVEL) { + vehicle.marker.getElement().style.visibility = 'hidden'; + } else { + vehicle.marker.getElement().style.visibility = 'visible'; + } + }); + } + + stopPopupOpen(stop_id: string) { + this.stops[stop_id].popup.setText("Loading..."); + + fetch(`${DEPARTURE_BOARD}?` + new URLSearchParams({ + format: "json", + id: stop_id, + duration: "240" + })) + .then(response => response.json()) + .then((data: HAFASDepartureBoard) => { + this.stops[stop_id].popup.setHTML(this.makeDepartureBoard(data)); + }) + .catch(error => { + console.error("Failed to load departure board", error); + this.stops[stop_id].popup.setText("Failed to load departure board"); + }); + } + + makeDepartureBoard(board: HAFASDepartureBoard) { + let html = `
`; + + board.Departure.forEach((departure) => { + console.log(departure.rtTime); + const scheduledTime = new Date(Date.parse(`${departure.date}T${departure.time}Z`)); + const rtTime = departure.rtTime ? new Date(Date.parse(`${departure.rtDate || departure.date}T${departure.rtTime}Z`)) : null; + const delay = rtTime ? rtTime.getTime() - scheduledTime.getTime() : 0; + + let timeClass; + if (!rtTime) { + timeClass = 'scheduled'; + } else if (delay <= 0) { + timeClass = 'on-time'; + } else if (delay <= 300000) { + timeClass = 'slightly-late'; + } else { + timeClass = 'late'; + } + + if (departure.cancelled) { + timeClass = 'cancelled'; + } + + html += `
`; + const time = (rtTime || scheduledTime).toLocaleTimeString(undefined, { + hour: '2-digit', + minute: '2-digit', + }); + html += `${time}`; + html += `${departure.direction}`; + if (departure.rtPlatform && !departure.rtPlatform.hidden) { + html += `Pl. ${departure.rtPlatform.text}`; + } + if (departure.Product) { + html += `A ${departure.Product[0].operatorInfo.name} service`; + } + departure.Notes.Note.forEach((note) => { + html += `${note.value}`; + }) + html += `
`; + }); + + html += `
`; + return html; + } +} \ No newline at end of file