From 72d499a48c335d0d2b30dde1ca2b1ef3051c6b6b Mon Sep 17 00:00:00 2001 From: Q Date: Thu, 14 Mar 2024 10:28:52 +0000 Subject: [PATCH 1/3] pull transit stop info from TfEMF --- web/public/icons/haltestelle.svg | 29 +++++++++ web/src/index.ts | 19 +++--- web/src/transit.css | 23 +++++++ web/src/transit.ts | 100 +++++++++++++++++++++++++++++++ 4 files changed, 163 insertions(+), 8 deletions(-) create mode 100644 web/public/icons/haltestelle.svg create mode 100644 web/src/transit.css create mode 100644 web/src/transit.ts 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/index.ts b/web/src/index.ts index ef1997c..01ecfb0 100644 --- a/web/src/index.ts +++ b/web/src/index.ts @@ -9,10 +9,11 @@ import DistanceMeasure from './distancemeasure' import ContextMenu from './contextmenu' import { roundPosition } from './util' import InstallControl from './installcontrol' +import TransitInfo from './transit' if (import.meta.env.DEV) { - map_style.sources.villages.data = 'http://localhost:2342/api/villages.geojson' - map_style.sources.site_plan.url = 'http://localhost:8888/capabilities/buildmap' +// map_style.sources.villages.data = 'http://localhost:2342/api/villages.geojson' +// map_style.sources.site_plan.url = 'http://localhost:8888/capabilities/buildmap' map_style.glyphs = 'http://localhost:8080/fonts/{fontstack}/{range}.pbf' } @@ -35,6 +36,7 @@ class EventMap { layer_switcher?: LayerSwitcher url_hash?: URLHash marker?: Marker + transit_info?: TransitInfo init() { registerSW({ immediate: true }) @@ -78,12 +80,11 @@ class EventMap { this.map.addControl(new DistanceMeasure(), 'top-right') this.map.addControl(new InstallControl(), 'top-left') - /* - map.addControl( - new VillagesEditor('villages', 'villages_symbol'), - 'top-right', - ); - */ + /*this.map.addControl( + new VillagesEditor('villages', 'villages_symbol'), + 'top-right', + );*/ + this.map.addControl(this.layer_switcher, 'top-right') this.url_hash.enable(this.map) @@ -105,6 +106,8 @@ class EventMap { const [lng, lat] = roundPosition([coords.lng, coords.lat], this.map!.getZoom()) navigator.clipboard.writeText(lat + ', ' + lng) }) + + 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..a3a558f --- /dev/null +++ b/web/src/transit.css @@ -0,0 +1,23 @@ +.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%; +} \ No newline at end of file diff --git a/web/src/transit.ts b/web/src/transit.ts new file mode 100644 index 0000000..96bac75 --- /dev/null +++ b/web/src/transit.ts @@ -0,0 +1,100 @@ +import './transit.css'; +import maplibregl from "maplibre-gl"; + +type Stop = { + marker: maplibregl.Marker; + popup: maplibregl.Popup; +} + +type Stops = { + [stop_id: string]: Stop; +}; + +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; + }; + }; +}; + +export default class TransitInfo { + _map: maplibregl.Map; + _gtfs_feed: string; + + stops: Stops; + + constructor(map: maplibregl.Map) { + this._map = map; + this._gtfs_feed = 'https://tracking.tfemf.uk/media/gtfs.json'; + + this.stops = {}; + + this.loadGTFSFeed(); + + this._map.on('zoom', this.updateStopVisibility.bind(this)); + } + + loadGTFSFeed() { + fetch(this._gtfs_feed) + .then(response => response.json()) + .then((data: GTFSFeed) => { + this.loadStops(data.stops); + }) + .catch(error => { + console.error("Failed to load GTFS feed", error); + }); + } + + 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 + }) + .setText(`Departures from ${stop.stop_name}`); + + const marker = new maplibregl.Marker({ + element: el + }) + .setLngLat([stop.stop_lon, stop.stop_lat]) + .setPopup(popup) + .addTo(this._map); + this.stops[stop.stop_id] = { + marker: marker, + popup: popup + }; + }); + } + + clearStops() { + Object.values(this.stops).forEach((stop) => { + stop.marker.remove(); + }); + } + + updateStopVisibility() { + const zoom = this._map.getZoom(); + Object.values(this.stops).forEach((stop) => { + if (zoom < 16) { + stop.marker.getElement().style.visibility = 'hidden'; + } else { + stop.marker.getElement().style.visibility = 'visible'; + } + }); + } +} \ No newline at end of file From 6eae77e3841526f50957ff94e57822e37a6f4228 Mon Sep 17 00:00:00 2001 From: Q Date: Fri, 17 May 2024 17:36:05 +0100 Subject: [PATCH 2/3] show bus positions and timetables --- web/src/icons/bus.svg | 103 ++-------------- web/src/index.ts | 4 +- web/src/transit.css | 93 ++++++++++++++ web/src/transit.ts | 277 +++++++++++++++++++++++++++++++++++++++++- 4 files changed, 375 insertions(+), 102 deletions(-) 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 2857c21..b516215 100644 --- a/web/src/index.ts +++ b/web/src/index.ts @@ -15,8 +15,8 @@ import ExportControl from './export/export' import { manifest } from 'virtual:render-svg' if (import.meta.env.DEV) { -// map_style.sources.villages.data = 'http://localhost:2342/api/villages.geojson' -// map_style.sources.site_plan.url = 'http://localhost:8888/capabilities/buildmap' + map_style.sources.villages.data = 'http://localhost:2342/api/villages.geojson' + map_style.sources.site_plan.url = 'http://localhost:8888/capabilities/buildmap' map_style.glyphs = 'http://localhost:8080/fonts/{fontstack}/{range}.pbf' } diff --git a/web/src/transit.css b/web/src/transit.css index a3a558f..49eda5f 100644 --- a/web/src/transit.css +++ b/web/src/transit.css @@ -20,4 +20,97 @@ 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 index 96bac75..86432a7 100644 --- a/web/src/transit.ts +++ b/web/src/transit.ts @@ -1,7 +1,15 @@ 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; } @@ -10,6 +18,15 @@ 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]: { @@ -23,36 +40,136 @@ type GTFSFeed = { 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; - _gtfs_feed: string; stops: Stops; + vehicles: Vehicles; + routes: GTFSFeed['routes']; + trips: GTFSFeed['trips']; constructor(map: maplibregl.Map) { this._map = map; - this._gtfs_feed = 'https://tracking.tfemf.uk/media/gtfs.json'; this.stops = {}; + this.vehicles = {}; + this.routes = {}; + this.trips = {}; this.loadGTFSFeed(); + this.updateGTFSRTFeed(); this._map.on('zoom', this.updateStopVisibility.bind(this)); } loadGTFSFeed() { - fetch(this._gtfs_feed) + 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(); @@ -66,7 +183,10 @@ export default class TransitInfo { className: 'popup-stop', offset: 25 }) - .setText(`Departures from ${stop.stop_name}`); + .setMaxWidth("80vw") + .on('open', () => { + this.stopPopupOpen(stop.stop_id); + }); const marker = new maplibregl.Marker({ element: el @@ -75,12 +195,87 @@ export default class TransitInfo { .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(); @@ -90,11 +285,83 @@ export default class TransitInfo { updateStopVisibility() { const zoom = this._map.getZoom(); Object.values(this.stops).forEach((stop) => { - if (zoom < 16) { + 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 From 89462d230caa989751ec527d00d51eae64b76734 Mon Sep 17 00:00:00 2001 From: Q Date: Sat, 25 May 2024 11:39:37 +0100 Subject: [PATCH 3/3] add .idea to gitignore --- .gitignore | 1 + .idea/.gitignore | 5 ----- .idea/emfcamp-map.iml | 12 ------------ .idea/inspectionProfiles/Project_Default.xml | 6 ------ .idea/jsLinters/jshint.xml | 16 ---------------- .idea/modules.xml | 8 -------- .idea/vcs.xml | 6 ------ 7 files changed, 1 insertion(+), 53 deletions(-) delete mode 100644 .idea/.gitignore delete mode 100644 .idea/emfcamp-map.iml delete mode 100644 .idea/inspectionProfiles/Project_Default.xml delete mode 100644 .idea/jsLinters/jshint.xml delete mode 100644 .idea/modules.xml delete mode 100644 .idea/vcs.xml 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/.idea/.gitignore b/.idea/.gitignore deleted file mode 100644 index b58b603..0000000 --- a/.idea/.gitignore +++ /dev/null @@ -1,5 +0,0 @@ -# Default ignored files -/shelf/ -/workspace.xml -# Editor-based HTTP Client requests -/httpRequests/ diff --git a/.idea/emfcamp-map.iml b/.idea/emfcamp-map.iml deleted file mode 100644 index 24643cc..0000000 --- a/.idea/emfcamp-map.iml +++ /dev/null @@ -1,12 +0,0 @@ - - - - - - - - - - - - \ No newline at end of file diff --git a/.idea/inspectionProfiles/Project_Default.xml b/.idea/inspectionProfiles/Project_Default.xml deleted file mode 100644 index eff7139..0000000 --- a/.idea/inspectionProfiles/Project_Default.xml +++ /dev/null @@ -1,6 +0,0 @@ - - - - \ No newline at end of file diff --git a/.idea/jsLinters/jshint.xml b/.idea/jsLinters/jshint.xml deleted file mode 100644 index 9a5c598..0000000 --- a/.idea/jsLinters/jshint.xml +++ /dev/null @@ -1,16 +0,0 @@ - - - - - \ No newline at end of file diff --git a/.idea/modules.xml b/.idea/modules.xml deleted file mode 100644 index 096edb1..0000000 --- a/.idea/modules.xml +++ /dev/null @@ -1,8 +0,0 @@ - - - - - - - - \ No newline at end of file diff --git a/.idea/vcs.xml b/.idea/vcs.xml deleted file mode 100644 index 35eb1dd..0000000 --- a/.idea/vcs.xml +++ /dev/null @@ -1,6 +0,0 @@ - - - - - - \ No newline at end of file