@transportme/load-ptv-gtfs 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (77) hide show
  1. package/.github/workflows/unit-test.yml +46 -0
  2. package/lib/constants.mjs +18 -0
  3. package/lib/gtfs-package/PTVGTFS.mjs +96 -0
  4. package/lib/gtfs-parser/GTFSAgency.mjs +15 -0
  5. package/lib/gtfs-parser/GTFSCalendar.mjs +63 -0
  6. package/lib/gtfs-parser/GTFSCalendarDate.mjs +15 -0
  7. package/lib/gtfs-parser/GTFSRoute.mjs +106 -0
  8. package/lib/gtfs-parser/GTFSShape.mjs +39 -0
  9. package/lib/gtfs-parser/GTFSStop.mjs +212 -0
  10. package/lib/gtfs-parser/GTFSStopTime.mjs +30 -0
  11. package/lib/gtfs-parser/GTFSTrip.mjs +169 -0
  12. package/lib/gtfs-parser/readers/GTFSAgencyReader.mjs +27 -0
  13. package/lib/gtfs-parser/readers/GTFSCalendarDatesReader.mjs +25 -0
  14. package/lib/gtfs-parser/readers/GTFSCalendarReader.mjs +63 -0
  15. package/lib/gtfs-parser/readers/GTFSReader.mjs +48 -0
  16. package/lib/gtfs-parser/readers/GTFSRouteReader.mjs +35 -0
  17. package/lib/gtfs-parser/readers/GTFSShapeReader.mjs +75 -0
  18. package/lib/gtfs-parser/readers/GTFSStopTimesReader.mjs +85 -0
  19. package/lib/gtfs-parser/readers/GTFSStopsReader.mjs +36 -0
  20. package/lib/gtfs-parser/readers/GTFSTripReader.mjs +61 -0
  21. package/lib/gtfs-parser/readers/line-reader.mjs +112 -0
  22. package/lib/loader/RouteLoader.mjs +101 -0
  23. package/lib/loader/ShapeLoader.mjs +86 -0
  24. package/lib/loader/StopsLoader.mjs +139 -0
  25. package/lib/loader/TripLoader.mjs +187 -0
  26. package/lib/post-processing/merge-stops.mjs +85 -0
  27. package/lib/post-processing/set-route-stops.mjs +73 -0
  28. package/lib/post-processing/set-stop-services.mjs +64 -0
  29. package/lib/utils.mjs +16 -0
  30. package/package.json +27 -0
  31. package/test/gtfs-calendar.test.mjs +182 -0
  32. package/test/gtfs-route-loader.test.mjs +190 -0
  33. package/test/gtfs-routes.test.mjs +77 -0
  34. package/test/gtfs-shapes-loader.test.mjs +105 -0
  35. package/test/gtfs-shapes.test.mjs +23 -0
  36. package/test/gtfs-stop-loader-metro.test.mjs +49 -0
  37. package/test/gtfs-stop-loader.test.mjs +223 -0
  38. package/test/gtfs-stop-times.test.mjs +37 -0
  39. package/test/gtfs-stops.test.mjs +290 -0
  40. package/test/gtfs-trip-loader.test.mjs +142 -0
  41. package/test/gtfs-trips.test.mjs +109 -0
  42. package/test/line-reader.test.mjs +94 -0
  43. package/test/merge-stops.test.mjs +376 -0
  44. package/test/ptv-gtfs.test.mjs +106 -0
  45. package/test/sample-data/calendar/calendar.txt +34 -0
  46. package/test/sample-data/calendar/calendar_dates.txt +26 -0
  47. package/test/sample-data/gtfs-splitting/force_unique_stops_bus.txt +7 -0
  48. package/test/sample-data/gtfs-splitting/force_unique_stops_tram.txt +3 -0
  49. package/test/sample-data/gtfs-splitting/line_reader_test.txt +8 -0
  50. package/test/sample-data/gtfs-splitting/name_overrides.txt +4 -0
  51. package/test/sample-data/gtfs-splitting/nov_2024_train_data.txt +6 -0
  52. package/test/sample-data/gtfs-splitting/sample_stops.txt +20 -0
  53. package/test/sample-data/gtfs-splitting/special_chars_stops_csv.txt +6 -0
  54. package/test/sample-data/gtfs-splitting/stops_without_secondary.txt +6 -0
  55. package/test/sample-data/gtfs-unzipping/gtfs.zip +0 -0
  56. package/test/sample-data/one_line.csv +2 -0
  57. package/test/sample-data/routes/agency.txt +11 -0
  58. package/test/sample-data/routes/metro_bus_routes.txt +4 -0
  59. package/test/sample-data/routes/metro_lines.txt +5 -0
  60. package/test/sample-data/routes/regional_bus_routes.txt +7 -0
  61. package/test/sample-data/routes/vline.txt +7 -0
  62. package/test/sample-data/routes/west_gipp_transit.txt +20 -0
  63. package/test/sample-data/shapes/shapes.txt +89 -0
  64. package/test/sample-data/shapes/stop_times.txt +9 -0
  65. package/test/sample-data/shapes/stops.txt +4 -0
  66. package/test/sample-data/shapes/trips.txt +4 -0
  67. package/test/sample-data/stop-data/name-overrides.json +25 -0
  68. package/test/sample-data/stop-data/unique-stops.json +1 -0
  69. package/test/sample-data/suburbs.json +77 -0
  70. package/test/sample-data/trips/bus_trips.txt +5 -0
  71. package/test/sample-data/trips/stop_times.txt +29 -0
  72. package/test/sample-data/trips/stop_times_new.txt +8 -0
  73. package/test/sample-data/trips/stops.txt +8 -0
  74. package/test/sample-data/trips/trips.txt +5 -0
  75. package/test/sample-data/trips/trips_new.txt +2 -0
  76. package/test/set-stop-services.test.mjs +69 -0
  77. package/test/utils-test.mjs +26 -0
@@ -0,0 +1,112 @@
1
+ import lineReader from 'line-reader'
2
+
3
+ export default class CSVLineReader {
4
+
5
+ /** @type {String[]} */
6
+ #headers
7
+ /** @type {string} */
8
+ #file
9
+ /** @type {boolean} */
10
+ #unread
11
+ /** @type {Object.<string, string>} */
12
+ #previousLine
13
+ /** @type {LineReader} */
14
+ #reader
15
+
16
+ /**
17
+ * Constructs a new CSVLineReader for a given file.
18
+ *
19
+ * @param {string} file The CSV file path
20
+ */
21
+ constructor(file) {
22
+ this.#headers = []
23
+ this.#file = file
24
+ this.#unread = false
25
+ this.#previousLine = null
26
+ }
27
+
28
+ /**
29
+ * Opens the CSV file for reading, and reads the first line for the headers.
30
+ */
31
+ async open() {
32
+ await new Promise((resolve, reject) => {
33
+ lineReader.open(this.#file, async (err, reader) => {
34
+ if (err) return reject(err)
35
+
36
+ this.#reader = reader
37
+ this.#headers = (await this.#readNextLine()).split(',')
38
+ resolve()
39
+ })
40
+ })
41
+ }
42
+
43
+ /**
44
+ * Checks if there is still more data available for reading
45
+ *
46
+ * @returns {boolean} If there is still a line available
47
+ */
48
+ available() {
49
+ return this.#unread || this.#reader.hasNextLine()
50
+ }
51
+
52
+ #readNextLine() {
53
+ return new Promise(resolve => {
54
+ this.#reader.nextLine((err, line) => {
55
+ resolve(line.trim())
56
+ })
57
+ })
58
+ }
59
+
60
+ #setHeaders(line) {
61
+ let parts = line.match(/"([^"]*)"/g).map(f => f.slice(1, -1))
62
+ return this.#headers.reduce((acc, header, i) => {
63
+ acc[header] = parts[i]
64
+ return acc
65
+ }, {})
66
+ }
67
+
68
+ /**
69
+ * Returns the next line of CSV data, taking into account any unreads.
70
+ *
71
+ * @async
72
+ * @returns {Promise<Object.<string,string>>} The line data, parsed according to the headers
73
+ */
74
+ async nextLine() {
75
+ if (this.#unread) {
76
+ this.#unread = false
77
+ return this.#previousLine
78
+ }
79
+
80
+ let line = this.#setHeaders(await this.#readNextLine())
81
+ this.#previousLine = line
82
+
83
+ return line
84
+ }
85
+
86
+ /**
87
+ * Unreads a line, causing it to be returned again during the next call to `nextLine()`
88
+ */
89
+ unreadLine() {
90
+ this.#unread = true
91
+ }
92
+
93
+ /**
94
+ * Closes the CSV line reader.
95
+ */
96
+ async close() {
97
+ await new Promise(resolve => {
98
+ this.#reader.close(() => {
99
+ resolve()
100
+ })
101
+ })
102
+ }
103
+
104
+ /**
105
+ * Returns the CSV headers.
106
+ *
107
+ * @returns {string[]} A list of headers
108
+ */
109
+ getHeaders() {
110
+ return this.#headers
111
+ }
112
+ }
@@ -0,0 +1,101 @@
1
+ import GTFSRouteReader from '../gtfs-parser/readers/GTFSRouteReader.mjs'
2
+ import GTFSAgencyReader from '../gtfs-parser/readers/GTFSAgencyReader.mjs'
3
+ import { STOP_UTILS } from '@transportme/transportvic-utils'
4
+
5
+ const { sanitiseName } = STOP_UTILS
6
+
7
+ export default class RouteLoader {
8
+
9
+ #routes
10
+ #mode
11
+ #routesReader
12
+ #operators = {}
13
+ #agencyFile
14
+
15
+ #routeIDMap = {}
16
+
17
+ constructor(routesFile, agencyFile, mode, database) {
18
+ this.#routes = database.getCollection('routes')
19
+ this.#mode = mode
20
+ this.#routesReader = new GTFSRouteReader(routesFile, mode)
21
+ this.#agencyFile = agencyFile
22
+ }
23
+
24
+ async loadAgencies() {
25
+ let agencyReader = new GTFSAgencyReader(this.#agencyFile)
26
+ await agencyReader.open()
27
+ while (agencyReader.available()) {
28
+ let agency = await agencyReader.getNextEntity()
29
+ this.#operators[agency.id] = agency
30
+ }
31
+ }
32
+
33
+ async findMatchingRoute(routeData) {
34
+ return await this.#routes.findDocument({
35
+ routeGTFSID: routeData.routeGTFSID
36
+ })
37
+ }
38
+
39
+ generateDBRouteData(route) {
40
+ return {
41
+ mode: this.#mode,
42
+ routeName: route.routeName,
43
+ cleanName: sanitiseName(route.routeName),
44
+ routeNumber: route.routeNumber,
45
+ routeGTFSID: route.routeGTFSID,
46
+ routePath: [],
47
+ operators: [ this.#operators[route.agencyID].name ],
48
+ directions: [],
49
+
50
+ codedName: sanitiseName(route.routeName)
51
+ }
52
+ }
53
+
54
+ async mergeRoutes(matchingRoute, routeData) {
55
+ if (!matchingRoute.operators.includes(routeData.operators[0])) {
56
+ matchingRoute.operators.push(routeData.operators[0])
57
+ }
58
+
59
+ await this.#routes.replaceDocument({
60
+ _id: matchingRoute._id
61
+ }, matchingRoute)
62
+ }
63
+
64
+ /**
65
+ * Loads the route data into the database.
66
+ *
67
+ * @param {Object} options An optional parameter to allow for custom route processing
68
+ * @param {function} options.processRoute A function to modify the route data. If a route should be dropped, it should return null.
69
+ */
70
+ async loadRoutes({ processRoute } = { processRoute: r => r }) {
71
+ await this.loadAgencies()
72
+
73
+ await this.#routesReader.open()
74
+ while (this.#routesReader.available()) {
75
+ let route = await this.#routesReader.getNextEntity()
76
+
77
+ let originalRouteID = route.originalRouteGTFSID
78
+ let routeData = this.generateDBRouteData(route)
79
+ let processedData = await processRoute(routeData)
80
+ if (!processedData) continue
81
+
82
+ this.#routeIDMap[originalRouteID] = processedData.routeGTFSID
83
+
84
+ let matchingRoute = await this.findMatchingRoute(processedData)
85
+
86
+ if (matchingRoute) {
87
+ await this.mergeRoutes(matchingRoute, processedData)
88
+ } else {
89
+ await this.#routes.createDocument(processedData)
90
+ }
91
+ }
92
+ }
93
+
94
+ getOperator(id) {
95
+ return this.#operators[id]
96
+ }
97
+
98
+ getRouteIDMap() {
99
+ return this.#routeIDMap
100
+ }
101
+ }
@@ -0,0 +1,86 @@
1
+ import { GTFSStop } from '../gtfs-parser/GTFSStop.mjs'
2
+ import GTFSShapeReader from '../gtfs-parser/readers/GTFSShapeReader.mjs'
3
+
4
+ export default class ShapeLoader {
5
+
6
+ #routes
7
+ #shapeReader
8
+
9
+ #routeCache = {}
10
+
11
+ constructor(shapeFile, database) {
12
+ this.#routes = database.getCollection('routes')
13
+ this.#shapeReader = new GTFSShapeReader(shapeFile)
14
+ }
15
+
16
+ async findMatchingRoute(routeGTFSID) {
17
+ if (this.#routeCache[routeGTFSID]) return this.#routeCache[routeGTFSID]
18
+
19
+ let route = await this.#routes.findDocument({
20
+ routeGTFSID: routeGTFSID
21
+ })
22
+
23
+ this.#routeCache[routeGTFSID] = route
24
+ return route
25
+ }
26
+
27
+ async updateRoute(routeGTFSID, routePath) {
28
+ this.#routeCache[routeGTFSID].routePath = routePath
29
+ await this.#routes.updateDocument({
30
+ routeGTFSID
31
+ }, { $set: { routePath } })
32
+ }
33
+
34
+ generateDBShapeData(shape) {
35
+ return {
36
+ fullGTFSIDs: [ shape.shapeID ],
37
+ hash: shape.hash,
38
+ length: shape.shapeLength,
39
+ path: {
40
+ type: 'LineString',
41
+ coordinates: shape.points.map(point => point.coordinates)
42
+ }
43
+ }
44
+ }
45
+
46
+ /**
47
+ * Loads a shape into the database.
48
+ *
49
+ * @param {GTFSStop} shape The shape being added
50
+ * @param {function} options.shapeIDMap A mapping of shape IDs to their trip IDs.
51
+ */
52
+ async loadShape(shape, shapeIDMap) {
53
+ let routeGTFSID = shapeIDMap[shape.shapeID]
54
+ if (!routeGTFSID) return // All trips corresponding to that shape must have been dropped
55
+
56
+ let routeData = await this.findMatchingRoute(routeGTFSID)
57
+
58
+ let matchingShape = routeData.routePath.find(path => path.hash === shape.hash)
59
+ if (matchingShape) {
60
+ matchingShape.fullGTFSIDs.push(shape.shapeID)
61
+ } else {
62
+ routeData.routePath.push(this.generateDBShapeData(shape))
63
+ }
64
+
65
+ await this.updateRoute(routeGTFSID, routeData.routePath)
66
+ }
67
+
68
+ /**
69
+ * Loads the shapes into the database.
70
+ *
71
+ * @param {object} options Options for loading the shape data in
72
+ * @param {function} options.shapeIDMap A mapping of shape IDs to their trip IDs.
73
+ */
74
+ async loadShapes({ shapeIDMap }) {
75
+ await this.#shapeReader.open()
76
+
77
+ let shapesLoaded = 0
78
+
79
+ while (this.#shapeReader.available()) {
80
+ let shape = await this.#shapeReader.getNextEntity()
81
+ await this.loadShape(shape, shapeIDMap)
82
+
83
+ if (++shapesLoaded === 1000) this.#routeCache = {}
84
+ }
85
+ }
86
+ }
@@ -0,0 +1,139 @@
1
+ import { STOP_UTILS } from '@transportme/transportvic-utils'
2
+ import { GTFSStop } from '../gtfs-parser/GTFSStop.mjs'
3
+ import GTFSStopsReader from '../gtfs-parser/readers/GTFSStopsReader.mjs'
4
+
5
+ const { getPrimaryStopName, isStreetStop, sanitiseName } = STOP_UTILS
6
+
7
+ export default class StopsLoader {
8
+
9
+ #stops
10
+ #mode
11
+ #stopsReader
12
+
13
+ constructor(stopsFile, suburbs, mode, database) {
14
+ this.#stops = database.getCollection('stops')
15
+ this.#mode = mode
16
+ this.#stopsReader = new GTFSStopsReader(stopsFile, suburbs)
17
+ }
18
+
19
+ getMergeName(stop) {
20
+ if (isStreetStop(stop.fullStopName)) return stop.fullStopName
21
+
22
+ return getPrimaryStopName(stop.fullStopName)
23
+ }
24
+
25
+ async findMatchingStop(stop, mergeName) {
26
+ return await this.#stops.findDocument({
27
+ mergeName,
28
+ location: {
29
+ $nearSphere: {
30
+ $geometry: stop.location,
31
+ $maxDistance: 500
32
+ }
33
+ }
34
+ })
35
+ }
36
+
37
+ generateDBStopData(stop, mergeName) {
38
+ let cleanName = sanitiseName(stop.fullStopName)
39
+
40
+ return {
41
+ stopName: stop.fullStopName,
42
+ suburb: [ stop.suburb ],
43
+ cleanSuburbs: [ sanitiseName(stop.suburb) ],
44
+ bays: [ stop.getBayData(this.#mode) ],
45
+ location: {
46
+ type: 'MultiPoint',
47
+ coordinates: [ stop.location.coordinates ]
48
+ },
49
+ mergeName,
50
+ services: [],
51
+ screenServices: [],
52
+ cleanName,
53
+ cleanNames: [ cleanName ],
54
+
55
+ codedSuburbs: [ sanitiseName(stop.suburb) ], // parity purposes
56
+ codedName: cleanName, // parity purposes
57
+ codedNames: [ cleanName ] // parity purposes
58
+ }
59
+ }
60
+
61
+ updateStopName(stop) {
62
+ stop.cleanNames = stop.bays
63
+ .map(bay => sanitiseName(bay.fullStopName))
64
+ .filter((e, i, a) => a.indexOf(e) === i)
65
+
66
+ if (stop.cleanNames.length > 1) {
67
+ stop.stopName = stop.mergeName
68
+ stop.cleanName = sanitiseName(stop.stopName)
69
+
70
+ if (!stop.cleanNames.includes(stop.cleanName)) stop.cleanNames.push(stop.cleanName)
71
+ }
72
+
73
+ stop.codedName = stop.cleanName
74
+ stop.codedNames = stop.cleanNames
75
+ }
76
+
77
+ updateStopSuburb(stop) {
78
+ stop.cleanSuburbs = stop.bays
79
+ .map(bay => sanitiseName(bay.suburb))
80
+ .filter((e, i, a) => a.indexOf(e) === i)
81
+
82
+ stop.suburb = stop.bays
83
+ .map(bay => bay.suburb)
84
+ .filter((e, i, a) => a.indexOf(e) === i)
85
+ .sort()
86
+
87
+ stop.codedSuburbs = stop.cleanSuburbs
88
+ }
89
+
90
+ async mergeStops(matchingStop, stopData) {
91
+ matchingStop.bays.push(stopData.bays[0])
92
+
93
+ this.updateStopSuburb(matchingStop)
94
+ this.updateStopName(matchingStop)
95
+
96
+ await this.#stops.replaceDocument({
97
+ _id: matchingStop._id
98
+ }, matchingStop)
99
+ }
100
+
101
+ /**
102
+ * Loads a stop into the database.
103
+ *
104
+ * @param {GTFSStop} stop The stop being added
105
+ * @param {object} options Options for loading the stop data in
106
+ * @param {function} options.processStop Used to process as stop's data.
107
+ * @param {function} options.getMergeName Used to get a stop's merge name. Return null to use the default merge name.
108
+ */
109
+ async loadStop(stop, {processStop, getMergeName} = {}) {
110
+ let processedData = processStop ? processStop(stop) : stop
111
+
112
+ let defaultMergeName = this.getMergeName(processedData)
113
+ let mergeName = getMergeName ? (getMergeName(processedData) || defaultMergeName) : defaultMergeName
114
+
115
+ let stopData = this.generateDBStopData(processedData, mergeName)
116
+ let matchingStop = await this.findMatchingStop(processedData, mergeName)
117
+
118
+ if (matchingStop) {
119
+ await this.mergeStops(matchingStop, stopData)
120
+ } else {
121
+ await this.#stops.createDocument(stopData)
122
+ }
123
+ }
124
+
125
+ /**
126
+ * Loads the stops into the database.
127
+ *
128
+ * @param {object} options Options for loading the stop data in
129
+ * @param {function} options.processStop Used to process as stop's data.
130
+ * @param {function} options.getMergeName Used to get a stop's merge name. Return null to use the default merge name.
131
+ */
132
+ async loadStops(options) {
133
+ await this.#stopsReader.open()
134
+ while (this.#stopsReader.available()) {
135
+ let stop = await this.#stopsReader.getNextEntity()
136
+ await this.loadStop(stop, options)
137
+ }
138
+ }
139
+ }
@@ -0,0 +1,187 @@
1
+ import GTFSCalendarReader from '../gtfs-parser/readers/GTFSCalendarReader.mjs'
2
+ import GTFSStopTimesReader from '../gtfs-parser/readers/GTFSStopTimesReader.mjs'
3
+ import GTFSTripReader from '../gtfs-parser/readers/GTFSTripReader.mjs'
4
+ import async from 'async'
5
+
6
+ export default class TripLoader {
7
+
8
+ #routes
9
+ #stops
10
+ #trips
11
+
12
+ #tripsReader
13
+ #stopTimesReader
14
+ #calendarReader
15
+
16
+ #mode
17
+
18
+ #tripDetails = {}
19
+
20
+ #calendarMap = {}
21
+ #routeIDMap = {}
22
+
23
+ #routeCache = {}
24
+ #stopCache = {}
25
+
26
+ #shapeIDMap = {}
27
+
28
+ constructor({ tripsFile, stopTimesFile, calendarFile, calendarDatesFile }, mode, database) {
29
+ this.#routes = database.getCollection('routes')
30
+ this.#stops = database.getCollection('stops')
31
+ this.#trips = database.getCollection('gtfs timetables')
32
+
33
+ this.#mode = mode
34
+
35
+ this.#stopTimesReader = new GTFSStopTimesReader(stopTimesFile, mode)
36
+ this.#tripsReader = new GTFSTripReader(tripsFile, null, null, mode)
37
+ this.#calendarReader = new GTFSCalendarReader(calendarFile, calendarDatesFile)
38
+ }
39
+
40
+ async loadTripsData() {
41
+ let tripsReader = this.#tripsReader
42
+ tripsReader.setCalendar(this.#calendarMap)
43
+ tripsReader.setRouteMappings(this.#routeIDMap)
44
+
45
+ await tripsReader.open()
46
+ while (tripsReader.available()) {
47
+ let trip = await tripsReader.getNextEntity()
48
+ this.#tripDetails[trip.getTripID()] = trip
49
+ }
50
+ }
51
+
52
+ async loadCalendarData() {
53
+ let calendarReader = this.#calendarReader
54
+
55
+ await calendarReader.open()
56
+ while (calendarReader.available()) {
57
+ let calendar = await calendarReader.getNextEntity()
58
+ this.#calendarMap[calendar.id] = calendar
59
+ }
60
+ }
61
+
62
+ async findMatchingRoute(routeGTFSID) {
63
+ if (this.#routeCache[routeGTFSID]) return this.#routeCache[routeGTFSID]
64
+
65
+ let route = await this.#routes.findDocument({
66
+ routeGTFSID: routeGTFSID
67
+ })
68
+
69
+ this.#routeCache[routeGTFSID] = route
70
+ return route
71
+ }
72
+
73
+ async findMatchingStop(stopGTFSID) {
74
+ if (this.#stopCache[stopGTFSID]) return this.#stopCache[stopGTFSID]
75
+
76
+ let stop = await this.#stops.findDocument({
77
+ 'bays.stopGTFSID': stopGTFSID
78
+ })
79
+
80
+ this.#stopCache[stopGTFSID] = stop
81
+ return stop
82
+ }
83
+
84
+ async generateDBTripData(tripData, stops) {
85
+ let routeData = await this.findMatchingRoute(tripData.getRouteGTFSID())
86
+ if (!routeData) return null
87
+
88
+ let stopTimings = await async.map(stops, async stop => {
89
+ let stopData = await this.findMatchingStop(stop.stopID)
90
+ let bay = stopData.bays.find(bay => bay.stopGTFSID === stop.stopID)
91
+
92
+ let data = {
93
+ stopName: bay.fullStopName,
94
+ stopNumber: bay.stopNumber,
95
+ suburb: bay.suburb,
96
+ stopGTFSID: stop.stopID,
97
+ arrivalTime: stop.arrivalTime,
98
+ arrivalTimeMinutes: stop.arrivalTimeMinutes,
99
+ departureTime: stop.departureTime,
100
+ departureTimeMinutes: stop.departureTimeMinutes,
101
+ stopConditions: { pickup: stop.pickup, dropoff: stop.dropoff },
102
+ stopDistance: stop.distanceTravelled
103
+ }
104
+
105
+ if (bay.platform) data.platform = bay.platform
106
+
107
+ return data
108
+ })
109
+
110
+
111
+ let dbTrip = {
112
+ mode: this.#mode,
113
+ ...tripData.getTripData(),
114
+ routeName: routeData.routeName,
115
+ routeNumber: routeData.routeNumber,
116
+ stopTimings
117
+ }
118
+
119
+ dbTrip.origin = stopTimings[0].stopName
120
+ dbTrip.departureTime = stopTimings[0].departureTime
121
+ stopTimings[0].arrivalTime = null
122
+ stopTimings[0].arrivalTimeMinutes = null
123
+ stopTimings[0].stopConditions.dropoff = 1
124
+
125
+ let lastStop = stopTimings[stopTimings.length - 1]
126
+ dbTrip.destination = lastStop.stopName
127
+ dbTrip.destinationArrivalTime = lastStop.arrivalTime
128
+ lastStop.departureTime = null
129
+ lastStop.departureTimeMinutes = null
130
+ lastStop.stopConditions.pickup = 1
131
+
132
+ return dbTrip
133
+ }
134
+
135
+ /**
136
+ * Loads the trip data into the database.
137
+ *
138
+ * @param {object} options A list of options for the trip loader
139
+ * @param {object} options.routeIDMap A mapping of full route IDs to their simplified IDs.
140
+ * @param {function} options.processTrip A function to modify trip data on a per-trip basis. It should return null to discard the trip.
141
+ */
142
+ async loadTrips({ routeIDMap, processTrip }) {
143
+ this.#routeIDMap = routeIDMap
144
+
145
+ await this.loadCalendarData()
146
+ await this.loadTripsData()
147
+
148
+ let tripsLoaded = 0
149
+ let trips = []
150
+
151
+ await this.#stopTimesReader.open()
152
+ while (this.#stopTimesReader.available()) {
153
+ let { tripID, stops } = await this.#stopTimesReader.getNextEntity()
154
+
155
+ let rawTripData = this.#tripDetails[tripID]
156
+ let tripData = await this.generateDBTripData(rawTripData, stops)
157
+ if (!tripData) continue
158
+
159
+ let processedData = processTrip ? await processTrip(tripData) : tripData
160
+ if (!processedData) continue
161
+
162
+ this.#shapeIDMap[tripData.shapeID] = tripData.routeGTFSID
163
+
164
+ trips.push(processedData)
165
+
166
+ tripsLoaded += 1
167
+
168
+ if (tripsLoaded === 5000) { // Flush the cache every 5000 trips
169
+ this.#routeCache = {}
170
+ this.#stopCache = {}
171
+
172
+ await this.#trips.createDocuments(trips)
173
+ trips = []
174
+
175
+ tripsLoaded = 0
176
+ }
177
+ }
178
+
179
+ if (trips.length) {
180
+ await this.#trips.createDocuments(trips)
181
+ }
182
+ }
183
+
184
+ getShapeIDMap() {
185
+ return this.#shapeIDMap
186
+ }
187
+ }
@@ -0,0 +1,85 @@
1
+ import { hash } from 'node:crypto'
2
+
3
+ function tripHash(trip) {
4
+ return parseInt(hash('md5', trip.map(stop => stop.stopGTFSID).join('-')), 16)
5
+ }
6
+
7
+ function isLoop(trip) {
8
+ return trip[0].stopName === trip[trip.length - 1].stopName
9
+ }
10
+
11
+ /**
12
+ * Merges different stopping patterns together to determine the overall stops for a route.
13
+ *
14
+ * @param {Object[][]} inputVariants A list of different stopping patterns being merged together
15
+ */
16
+ export default function merge(inputVariants) {
17
+ let loopVariants = [], nonLoopVariants = []
18
+
19
+ inputVariants.toSorted((a, b) => {
20
+ return b.length - a.length || tripHash(a) - tripHash(b)
21
+ }).forEach(trip => {
22
+ if (trip.length === 1) return
23
+ if (isLoop(trip)) loopVariants.push(trip)
24
+ else nonLoopVariants.push(trip)
25
+ })
26
+
27
+ let variants = loopVariants.concat(nonLoopVariants)
28
+ let stopsList = variants[0]
29
+
30
+ for (let variant of variants.slice(1)) {
31
+ let branch = []
32
+ let lastMainMatch = 0
33
+ let isLoopVariant = isLoop(variant)
34
+
35
+ let stopIndex = -1
36
+
37
+ for (let variantStop of variant) {
38
+ stopIndex++
39
+
40
+ let matchIndex = lastMainMatch - 1
41
+
42
+ let hasMatched = false
43
+ for (let stop of stopsList.slice(lastMainMatch)) {
44
+ matchIndex++
45
+
46
+ hasMatched = stop.stopName === variantStop.stopName
47
+ if (hasMatched) {
48
+ if (branch.length > 0) { // lines are out of sync, but match detected = rejoining
49
+ let firstHalf = stopsList.slice(0, matchIndex)
50
+ let backHalf = stopsList.slice(matchIndex)
51
+
52
+ stopsList = firstHalf.concat(branch).concat(backHalf)
53
+ branch = []
54
+ } else { // otherwise we're on sync, all good
55
+ if (matchIndex < lastMainMatch) {
56
+ let jumpPart = variant.slice(matchIndex, lastMainMatch + 1)
57
+ let subBranch = []
58
+ for (let jumpStop of jumpPart) {
59
+ if (matched(jumpStop, variantStop)) break
60
+ subBranch.push(jumpStop)
61
+ }
62
+ branch = branch.concat(subBranch)
63
+ }
64
+ lastMainMatch = matchIndex
65
+ }
66
+ break
67
+ }
68
+ }
69
+
70
+ // no match, we're on a branch
71
+ if (!hasMatched) branch.push(variantStop)
72
+ }
73
+
74
+ if (branch.length) { // we're still on a branch after completing the stops, means they have different destiantions
75
+ // look at where they deviated, and join it in between
76
+
77
+ let firstHalf = stopsList.slice(0, lastMainMatch + 1)
78
+ let backHalf = stopsList.slice(lastMainMatch + 1)
79
+
80
+ stopsList = firstHalf.concat(branch).concat(backHalf)
81
+ }
82
+ }
83
+
84
+ return stopsList
85
+ }