@osmix/gtfs 0.0.1

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 (60) hide show
  1. package/CHANGELOG.md +9 -0
  2. package/README.md +200 -0
  3. package/dist/from-gtfs.d.ts +76 -0
  4. package/dist/from-gtfs.d.ts.map +1 -0
  5. package/dist/from-gtfs.js +211 -0
  6. package/dist/from-gtfs.js.map +1 -0
  7. package/dist/gtfs-archive.d.ts +71 -0
  8. package/dist/gtfs-archive.d.ts.map +1 -0
  9. package/dist/gtfs-archive.js +102 -0
  10. package/dist/gtfs-archive.js.map +1 -0
  11. package/dist/index.d.ts +39 -0
  12. package/dist/index.d.ts.map +1 -0
  13. package/dist/index.js +39 -0
  14. package/dist/index.js.map +1 -0
  15. package/dist/src/from-gtfs.d.ts +76 -0
  16. package/dist/src/from-gtfs.d.ts.map +1 -0
  17. package/dist/src/from-gtfs.js +211 -0
  18. package/dist/src/from-gtfs.js.map +1 -0
  19. package/dist/src/gtfs-archive.d.ts +71 -0
  20. package/dist/src/gtfs-archive.d.ts.map +1 -0
  21. package/dist/src/gtfs-archive.js +102 -0
  22. package/dist/src/gtfs-archive.js.map +1 -0
  23. package/dist/src/index.d.ts +39 -0
  24. package/dist/src/index.d.ts.map +1 -0
  25. package/dist/src/index.js +39 -0
  26. package/dist/src/index.js.map +1 -0
  27. package/dist/src/types.d.ts +139 -0
  28. package/dist/src/types.d.ts.map +1 -0
  29. package/dist/src/types.js +48 -0
  30. package/dist/src/types.js.map +1 -0
  31. package/dist/src/utils.d.ts +25 -0
  32. package/dist/src/utils.d.ts.map +1 -0
  33. package/dist/src/utils.js +210 -0
  34. package/dist/src/utils.js.map +1 -0
  35. package/dist/test/from-gtfs.test.d.ts +2 -0
  36. package/dist/test/from-gtfs.test.d.ts.map +1 -0
  37. package/dist/test/from-gtfs.test.js +389 -0
  38. package/dist/test/from-gtfs.test.js.map +1 -0
  39. package/dist/test/helpers.d.ts +14 -0
  40. package/dist/test/helpers.d.ts.map +1 -0
  41. package/dist/test/helpers.js +84 -0
  42. package/dist/test/helpers.js.map +1 -0
  43. package/dist/types.d.ts +139 -0
  44. package/dist/types.d.ts.map +1 -0
  45. package/dist/types.js +48 -0
  46. package/dist/types.js.map +1 -0
  47. package/dist/utils.d.ts +25 -0
  48. package/dist/utils.d.ts.map +1 -0
  49. package/dist/utils.js +211 -0
  50. package/dist/utils.js.map +1 -0
  51. package/package.json +53 -0
  52. package/src/from-gtfs.ts +259 -0
  53. package/src/gtfs-archive.ts +138 -0
  54. package/src/index.ts +54 -0
  55. package/src/types.ts +184 -0
  56. package/src/utils.ts +226 -0
  57. package/test/from-gtfs.test.ts +501 -0
  58. package/test/helpers.ts +118 -0
  59. package/tsconfig.build.json +5 -0
  60. package/tsconfig.json +9 -0
@@ -0,0 +1 @@
1
+ {"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":"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"}
package/dist/types.js ADDED
@@ -0,0 +1,48 @@
1
+ /**
2
+ * GTFS (General Transit Feed Specification) type definitions.
3
+ *
4
+ * @module
5
+ */
6
+ /**
7
+ * Map GTFS route_type to OSM route tag value.
8
+ */
9
+ export function routeTypeToOsmRoute(routeType) {
10
+ switch (routeType) {
11
+ case "0":
12
+ return "tram";
13
+ case "1":
14
+ return "subway";
15
+ case "2":
16
+ return "train";
17
+ case "3":
18
+ return "bus";
19
+ case "4":
20
+ return "ferry";
21
+ case "5":
22
+ return "tram"; // Cable tram
23
+ case "6":
24
+ return "aerialway";
25
+ case "7":
26
+ return "funicular";
27
+ case "11":
28
+ return "trolleybus";
29
+ case "12":
30
+ return "train"; // Monorail
31
+ default:
32
+ return "bus";
33
+ }
34
+ }
35
+ /**
36
+ * Map GTFS wheelchair_boarding to OSM wheelchair tag value.
37
+ */
38
+ export function wheelchairBoardingToOsm(value) {
39
+ switch (value) {
40
+ case "1":
41
+ return "yes";
42
+ case "2":
43
+ return "no";
44
+ default:
45
+ return undefined;
46
+ }
47
+ }
48
+ //# sourceMappingURL=types.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"types.js","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":"AAAA;;;;GAIG;AAuIH;;GAEG;AACH,MAAM,UAAU,mBAAmB,CAAC,SAAiB;IACpD,QAAQ,SAAS,EAAE,CAAC;QACnB,KAAK,GAAG;YACP,OAAO,MAAM,CAAA;QACd,KAAK,GAAG;YACP,OAAO,QAAQ,CAAA;QAChB,KAAK,GAAG;YACP,OAAO,OAAO,CAAA;QACf,KAAK,GAAG;YACP,OAAO,KAAK,CAAA;QACb,KAAK,GAAG;YACP,OAAO,OAAO,CAAA;QACf,KAAK,GAAG;YACP,OAAO,MAAM,CAAA,CAAC,aAAa;QAC5B,KAAK,GAAG;YACP,OAAO,WAAW,CAAA;QACnB,KAAK,GAAG;YACP,OAAO,WAAW,CAAA;QACnB,KAAK,IAAI;YACR,OAAO,YAAY,CAAA;QACpB,KAAK,IAAI;YACR,OAAO,OAAO,CAAA,CAAC,WAAW;QAC3B;YACC,OAAO,KAAK,CAAA;IACd,CAAC;AACF,CAAC;AAED;;GAEG;AACH,MAAM,UAAU,uBAAuB,CACtC,KAAyB;IAEzB,QAAQ,KAAK,EAAE,CAAC;QACf,KAAK,GAAG;YACP,OAAO,KAAK,CAAA;QACb,KAAK,GAAG;YACP,OAAO,IAAI,CAAA;QACZ;YACC,OAAO,SAAS,CAAA;IAClB,CAAC;AACF,CAAC"}
@@ -0,0 +1,25 @@
1
+ import type { OsmTags } from "@osmix/shared/types";
2
+ import { type GtfsRoute, type GtfsStop, type GtfsTrip } from "./types";
3
+ /**
4
+ * Check if a ZIP file (as bytes) is a GTFS archive by looking for characteristic GTFS files.
5
+ * GTFS archives must contain at least agency.txt, stops.txt, routes.txt, trips.txt,
6
+ * and stop_times.txt according to the GTFS specification.
7
+ */
8
+ export declare function isGtfsZip(bytes: Uint8Array): boolean;
9
+ /**
10
+ * Convert a GTFS stop to OSM tags.
11
+ */
12
+ export declare function stopToTags(stop: GtfsStop): OsmTags;
13
+ /**
14
+ * Convert a GTFS route to OSM tags.
15
+ */
16
+ export declare function routeToTags(route: GtfsRoute): OsmTags;
17
+ /**
18
+ * Convert a GTFS trip to OSM tags.
19
+ */
20
+ export declare function tripToTags(trip: GtfsTrip): OsmTags;
21
+ /**
22
+ * Create a ReadableStream of text from file bytes.
23
+ */
24
+ export declare function bytesToTextStream(bytes: Uint8Array): ReadableStream<string>;
25
+ //# sourceMappingURL=utils.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"utils.d.ts","sourceRoot":"","sources":["../src/utils.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,qBAAqB,CAAA;AAClD,OAAO,EACN,KAAK,SAAS,EACd,KAAK,QAAQ,EACb,KAAK,QAAQ,EAGb,MAAM,SAAS,CAAA;AAEhB;;;;GAIG;AACH,wBAAgB,SAAS,CAAC,KAAK,EAAE,UAAU,GAAG,OAAO,CAsFpD;AAED;;GAEG;AACH,wBAAgB,UAAU,CAAC,IAAI,EAAE,QAAQ,GAAG,OAAO,CAoClD;AAED;;GAEG;AACH,wBAAgB,WAAW,CAAC,KAAK,EAAE,SAAS,GAAG,OAAO,CAoCrD;AAED;;GAEG;AACH,wBAAgB,UAAU,CAAC,IAAI,EAAE,QAAQ,GAAG,OAAO,CAelD;AAED;;GAEG;AACH,wBAAgB,iBAAiB,CAAC,KAAK,EAAE,UAAU,GAAG,cAAc,CAAC,MAAM,CAAC,CAqB3E"}
package/dist/utils.js ADDED
@@ -0,0 +1,211 @@
1
+ import { routeTypeToOsmRoute, wheelchairBoardingToOsm, } from "./types";
2
+ /**
3
+ * Check if a ZIP file (as bytes) is a GTFS archive by looking for characteristic GTFS files.
4
+ * GTFS archives must contain at least agency.txt, stops.txt, routes.txt, trips.txt,
5
+ * and stop_times.txt according to the GTFS specification.
6
+ */
7
+ export function isGtfsZip(bytes) {
8
+ // GTFS required files per the spec
9
+ const requiredGtfsFiles = [
10
+ "agency.txt",
11
+ "stops.txt",
12
+ "routes.txt",
13
+ "trips.txt",
14
+ "stop_times.txt",
15
+ ];
16
+ // Find filenames in the ZIP by scanning for the local file headers
17
+ // ZIP local file header signature: 0x04034b50 (little-endian: 50 4b 03 04)
18
+ const foundFiles = new Set();
19
+ const decoder = new TextDecoder();
20
+ let pos = 0;
21
+ while (pos < bytes.length - 30) {
22
+ // Check for ZIP local file header signature
23
+ const isLocalHeader = bytes[pos] === 0x50 &&
24
+ bytes[pos + 1] === 0x4b &&
25
+ bytes[pos + 2] === 0x03 &&
26
+ bytes[pos + 3] === 0x04;
27
+ if (isLocalHeader) {
28
+ // Read filename length at offset 26-27 (little-endian)
29
+ const nameLen = (bytes[pos + 26] ?? 0) | ((bytes[pos + 27] ?? 0) << 8);
30
+ // Read extra field length at offset 28-29 (little-endian)
31
+ const extraLen = (bytes[pos + 28] ?? 0) | ((bytes[pos + 29] ?? 0) << 8);
32
+ // Defensive bounds check
33
+ if (nameLen < 0 || extraLen < 0)
34
+ break;
35
+ if (pos + 30 + nameLen + extraLen > bytes.length)
36
+ break;
37
+ // General purpose bit flag at offset 6-7 (little-endian)
38
+ // Bit 3 indicates the presence of a data descriptor, in which case
39
+ // the compressed size fields in the local header are zero and the
40
+ // actual sizes follow the compressed data.
41
+ const flags = (bytes[pos + 6] ?? 0) | ((bytes[pos + 7] ?? 0) << 8);
42
+ const hasDataDescriptor = (flags & 0x0008) !== 0;
43
+ // Extract filename (starts at offset 30)
44
+ const nameBytes = bytes.slice(pos + 30, pos + 30 + nameLen);
45
+ const filename = decoder.decode(nameBytes);
46
+ // Normalize path - extract just the filename part
47
+ const basename = filename.replace(/^.*\//, "").toLowerCase();
48
+ if (basename) {
49
+ foundFiles.add(basename);
50
+ }
51
+ if (!hasDataDescriptor) {
52
+ // Read compressed size at offset 18-21 (little-endian)
53
+ const compSize = (bytes[pos + 18] ?? 0) |
54
+ ((bytes[pos + 19] ?? 0) << 8) |
55
+ ((bytes[pos + 20] ?? 0) << 16) |
56
+ ((bytes[pos + 21] ?? 0) << 24);
57
+ // Move to next entry using the known compressed size
58
+ pos += 30 + nameLen + extraLen + compSize;
59
+ }
60
+ else {
61
+ // When a data descriptor is present, the compressed size is not
62
+ // available in the local header. Skip past the header, filename,
63
+ // and extra fields, then scan forward for the next local header
64
+ // signature. This avoids getting stuck at the same position when
65
+ // the compressed size field is zero.
66
+ pos += 30 + nameLen + extraLen;
67
+ while (pos < bytes.length - 3) {
68
+ const nextIsHeader = bytes[pos] === 0x50 &&
69
+ bytes[pos + 1] === 0x4b &&
70
+ bytes[pos + 2] === 0x03 &&
71
+ bytes[pos + 3] === 0x04;
72
+ if (nextIsHeader)
73
+ break;
74
+ pos++;
75
+ }
76
+ }
77
+ }
78
+ else {
79
+ pos++;
80
+ }
81
+ }
82
+ // Check if all required GTFS files are present
83
+ return requiredGtfsFiles.every((f) => foundFiles.has(f));
84
+ }
85
+ /**
86
+ * Convert a GTFS stop to OSM tags.
87
+ */
88
+ export function stopToTags(stop) {
89
+ const tags = {
90
+ public_transport: "platform",
91
+ };
92
+ if (stop.stop_name)
93
+ tags["name"] = stop.stop_name;
94
+ if (stop.stop_id)
95
+ tags["ref"] = stop.stop_id;
96
+ if (stop.stop_code)
97
+ tags["ref:gtfs:stop_code"] = stop.stop_code;
98
+ if (stop.stop_desc)
99
+ tags["description"] = stop.stop_desc;
100
+ if (stop.stop_url)
101
+ tags["website"] = stop.stop_url;
102
+ if (stop.platform_code)
103
+ tags["ref:platform"] = stop.platform_code;
104
+ // Location type determines more specific tagging
105
+ const locationType = stop.location_type ?? "0";
106
+ switch (locationType) {
107
+ case "1":
108
+ tags["public_transport"] = "station";
109
+ break;
110
+ case "2":
111
+ // Entrances are not platforms - remove the default and use railway tag
112
+ delete tags["public_transport"];
113
+ tags["railway"] = "subway_entrance";
114
+ break;
115
+ case "3":
116
+ // Generic node - keep as platform
117
+ break;
118
+ case "4":
119
+ tags["public_transport"] = "platform";
120
+ break;
121
+ }
122
+ // Wheelchair accessibility
123
+ const wheelchair = wheelchairBoardingToOsm(stop.wheelchair_boarding);
124
+ if (wheelchair)
125
+ tags["wheelchair"] = wheelchair;
126
+ return tags;
127
+ }
128
+ /**
129
+ * Convert a GTFS route to OSM tags.
130
+ */
131
+ export function routeToTags(route) {
132
+ const tags = {
133
+ route: routeTypeToOsmRoute(route.route_type),
134
+ };
135
+ // Use long name if available, otherwise short name
136
+ if (route.route_long_name) {
137
+ tags["name"] = route.route_long_name;
138
+ }
139
+ else if (route.route_short_name) {
140
+ tags["name"] = route.route_short_name;
141
+ }
142
+ if (route.route_short_name)
143
+ tags["ref"] = route.route_short_name;
144
+ if (route.route_id)
145
+ tags["ref:gtfs:route_id"] = route.route_id;
146
+ if (route.route_desc)
147
+ tags["description"] = route.route_desc;
148
+ if (route.route_url)
149
+ tags["website"] = route.route_url;
150
+ // Route color (normalize to include # prefix)
151
+ if (route.route_color) {
152
+ const color = route.route_color.startsWith("#")
153
+ ? route.route_color
154
+ : `#${route.route_color}`;
155
+ tags["color"] = color;
156
+ }
157
+ if (route.route_text_color) {
158
+ const textColor = route.route_text_color.startsWith("#")
159
+ ? route.route_text_color
160
+ : `#${route.route_text_color}`;
161
+ tags["text_color"] = textColor;
162
+ }
163
+ // Route type as additional tag
164
+ tags["gtfs:route_type"] = route.route_type;
165
+ return tags;
166
+ }
167
+ /**
168
+ * Convert a GTFS trip to OSM tags.
169
+ */
170
+ export function tripToTags(trip) {
171
+ const tags = {};
172
+ tags["ref:gtfs:trip_id"] = trip.trip_id;
173
+ if (trip.service_id)
174
+ tags["ref:gtfs:service_id"] = trip.service_id;
175
+ if (trip.trip_headsign)
176
+ tags["ref:gtfs:trip_headsign"] = trip.trip_headsign;
177
+ if (trip.trip_short_name)
178
+ tags["ref:gtfs:trip_short_name"] = trip.trip_short_name;
179
+ if (trip.direction_id)
180
+ tags["ref:gtfs:direction_id"] = trip.direction_id;
181
+ if (trip.block_id)
182
+ tags["ref:gtfs:block_id"] = trip.block_id;
183
+ if (trip.shape_id)
184
+ tags["ref:gtfs:shape_id"] = trip.shape_id;
185
+ if (trip.wheelchair_accessible)
186
+ tags["ref:gtfs:wheelchair_accessible"] = trip.wheelchair_accessible;
187
+ if (trip.bikes_allowed)
188
+ tags["ref:gtfs:bikes_allowed"] = trip.bikes_allowed;
189
+ return tags;
190
+ }
191
+ /**
192
+ * Create a ReadableStream of text from file bytes.
193
+ */
194
+ export function bytesToTextStream(bytes) {
195
+ const decoder = new TextDecoder();
196
+ let offset = 0;
197
+ const chunkSize = 64 * 1024; // 64KB chunks
198
+ return new ReadableStream({
199
+ pull(controller) {
200
+ if (offset >= bytes.length) {
201
+ controller.close();
202
+ return;
203
+ }
204
+ const end = Math.min(offset + chunkSize, bytes.length);
205
+ const chunk = bytes.subarray(offset, end);
206
+ offset = end;
207
+ controller.enqueue(decoder.decode(chunk, { stream: offset < bytes.length }));
208
+ },
209
+ });
210
+ }
211
+ //# sourceMappingURL=utils.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"utils.js","sourceRoot":"","sources":["../src/utils.ts"],"names":[],"mappings":"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"}
package/package.json ADDED
@@ -0,0 +1,53 @@
1
+ {
2
+ "$schema": "https://json.schemastore.org/package",
3
+ "name": "@osmix/gtfs",
4
+ "description": "Convert GTFS transit feeds to OSM format.",
5
+ "version": "0.0.1",
6
+ "type": "module",
7
+ "main": "./src/index.ts",
8
+ "types": "./src/index.ts",
9
+ "publishConfig": {
10
+ "access": "public",
11
+ "main": "./dist/index.js",
12
+ "types": "./dist/index.d.ts",
13
+ "exports": {
14
+ ".": {
15
+ "import": "./dist/index.js",
16
+ "types": "./dist/index.d.ts"
17
+ }
18
+ },
19
+ "files": [
20
+ "dist",
21
+ "README.md",
22
+ "LICENSE"
23
+ ]
24
+ },
25
+ "license": "MIT",
26
+ "repository": {
27
+ "type": "git",
28
+ "url": "git+https://github.com/conveyal/osmix.git"
29
+ },
30
+ "homepage": "https://github.com/conveyal/osmix#readme",
31
+ "bugs": {
32
+ "url": "https://github.com/conveyal/osmix/issues"
33
+ },
34
+ "sideEffects": false,
35
+ "scripts": {
36
+ "build": "tsc -p tsconfig.build.json",
37
+ "bench": "bun test --bench",
38
+ "prepublishOnly": "tsc",
39
+ "test": "bun test",
40
+ "typecheck": "tsgo --noEmit"
41
+ },
42
+ "devDependencies": {
43
+ "@types/bun": "catalog:",
44
+ "fflate": "^0.8.2",
45
+ "typescript": "catalog:"
46
+ },
47
+ "dependencies": {
48
+ "@osmix/core": "workspace:*",
49
+ "@osmix/shared": "workspace:*",
50
+ "@std/csv": "npm:@jsr/std__csv",
51
+ "but-unzip": "^0.1.7"
52
+ }
53
+ }
@@ -0,0 +1,259 @@
1
+ /**
2
+ * GTFS to OSM conversion utilities.
3
+ *
4
+ * Imports GTFS transit feeds into Osm indexes, mapping:
5
+ * - Stops → Nodes with transit tags
6
+ * - Routes → Ways with shape geometry
7
+ *
8
+ * Uses lazy, on-demand parsing - files are only parsed when needed.
9
+ *
10
+ * @module
11
+ */
12
+
13
+ import { Osm, type OsmOptions } from "@osmix/core"
14
+ import {
15
+ logProgress,
16
+ type ProgressEvent,
17
+ progressEvent,
18
+ } from "@osmix/shared/progress"
19
+ import type { OsmTags } from "@osmix/shared/types"
20
+ import { GtfsArchive } from "./gtfs-archive"
21
+ import type { GtfsConversionOptions, GtfsShapePoint } from "./types"
22
+ import { routeToTags, stopToTags } from "./utils"
23
+
24
+ /**
25
+ * Create an Osm index from a zipped GTFS file.
26
+ *
27
+ * Parses the GTFS zip lazily - only reading files when needed.
28
+ * Converts stops and routes to OSM entities:
29
+ * - Stops become nodes with transit-related tags
30
+ * - Routes become ways with shape geometry (if available) or stop sequence
31
+ *
32
+ * @param zipData - The GTFS zip file as ArrayBuffer or Uint8Array
33
+ * @param options - Osm index options (id, header)
34
+ * @param gtfsOptions - GTFS conversion options
35
+ * @param onProgress - Progress callback for UI feedback
36
+ * @returns Populated Osm index with built indexes
37
+ *
38
+ * @example
39
+ * ```ts
40
+ * import { fromGtfs } from "@osmix/gtfs"
41
+ *
42
+ * const response = await fetch("https://example.com/gtfs.zip")
43
+ * const zipData = await response.arrayBuffer()
44
+ * const osm = await fromGtfs(zipData, { id: "transit" })
45
+ *
46
+ * console.log(`Imported ${osm.nodes.size} stops`)
47
+ * ```
48
+ */
49
+ export async function fromGtfs(
50
+ zipData: ArrayBuffer | Uint8Array,
51
+ options: Partial<OsmOptions> = {},
52
+ gtfsOptions: GtfsConversionOptions = {},
53
+ onProgress: (progress: ProgressEvent) => void = logProgress,
54
+ ): Promise<Osm> {
55
+ onProgress(progressEvent("Opening GTFS archive..."))
56
+ const archive = GtfsArchive.fromZip(zipData)
57
+ const builder = new GtfsOsmBuilder(options, onProgress)
58
+ if (gtfsOptions.includeStops ?? true) {
59
+ await builder.processStops(archive)
60
+ }
61
+
62
+ if (gtfsOptions.includeRoutes ?? true) {
63
+ await builder.processRoutes(archive)
64
+ }
65
+
66
+ return builder.buildOsm()
67
+ }
68
+
69
+ /**
70
+ * Builder class for converting GTFS data to OSM entities.
71
+ *
72
+ * Uses lazy parsing - only reads GTFS files when needed.
73
+ */
74
+ export class GtfsOsmBuilder {
75
+ private osm: Osm
76
+ private onProgress: (progress: ProgressEvent) => void
77
+
78
+ private nextNodeId = -1
79
+ private nextWayId = -1
80
+
81
+ // Map GTFS stop_id to OSM node ID
82
+ private stopIdToNodeId = new Map<string, number>()
83
+
84
+ constructor(
85
+ osmOptions: Partial<OsmOptions> = {},
86
+ onProgress: (progress: ProgressEvent) => void = logProgress,
87
+ ) {
88
+ this.osm = new Osm(osmOptions)
89
+ this.onProgress = onProgress
90
+ }
91
+
92
+ /**
93
+ * Process GTFS stops into OSM nodes.
94
+ * Uses streaming iteration to avoid loading all stops at once.
95
+ */
96
+ async processStops(archive: GtfsArchive) {
97
+ let count = 0
98
+
99
+ this.onProgress(progressEvent("Processing stops..."))
100
+
101
+ for await (const stop of archive.iter("stops.txt")) {
102
+ const lat = Number.parseFloat(stop.stop_lat)
103
+ const lon = Number.parseFloat(stop.stop_lon)
104
+
105
+ if (Number.isNaN(lat) || Number.isNaN(lon)) continue
106
+
107
+ const tags = stopToTags(stop)
108
+ const nodeId = this.nextNodeId--
109
+
110
+ this.osm.nodes.addNode({
111
+ id: nodeId,
112
+ lat,
113
+ lon,
114
+ tags,
115
+ })
116
+
117
+ this.stopIdToNodeId.set(stop.stop_id, nodeId)
118
+ count++
119
+
120
+ if (count % 1000 === 0) {
121
+ this.onProgress(progressEvent(`Processed ${count} stops...`))
122
+ }
123
+ }
124
+
125
+ this.onProgress(progressEvent(`Added ${count} stops as nodes`))
126
+ }
127
+
128
+ /**
129
+ * Process GTFS routes into OSM ways.
130
+ * Creates one way per unique (shape_id, route_id) pair, so each route gets
131
+ * its own way with correct metadata even when routes share the same shape.
132
+ *
133
+ * @param archive - The GTFS archive
134
+ */
135
+ async processRoutes(archive: GtfsArchive) {
136
+ this.onProgress(progressEvent("Processing routes..."))
137
+
138
+ // Build shape lookup if shapes exist
139
+ const shapeMap = new Map<string, GtfsShapePoint[]>()
140
+ if (archive.hasFile("shapes.txt")) {
141
+ this.onProgress(progressEvent("Loading shape data..."))
142
+ for await (const point of archive.iter("shapes.txt")) {
143
+ const points = shapeMap.get(point.shape_id) ?? []
144
+ points.push(point)
145
+ shapeMap.set(point.shape_id, points)
146
+ }
147
+ // Sort each shape by sequence
148
+ for (const points of shapeMap.values()) {
149
+ points.sort(
150
+ (a, b) =>
151
+ Number.parseInt(a.shape_pt_sequence, 10) -
152
+ Number.parseInt(b.shape_pt_sequence, 10),
153
+ )
154
+ }
155
+ } else {
156
+ throw Error("No shape data found. Cannot process routes.")
157
+ }
158
+
159
+ // Group trips by (shape_id, route_id) to ensure each route gets its own way
160
+ // even when multiple routes share the same shape geometry
161
+ const shapeRouteToTrips = new Map<string, { tripIds: string[] }>()
162
+ this.onProgress(progressEvent("Loading trip data..."))
163
+ for await (const trip of archive.iter("trips.txt")) {
164
+ if (!trip.shape_id) continue
165
+
166
+ const key = `${trip.shape_id}:${trip.route_id}`
167
+ const existing = shapeRouteToTrips.get(key)
168
+ if (existing) {
169
+ existing.tripIds.push(trip.trip_id)
170
+ } else {
171
+ shapeRouteToTrips.set(key, { tripIds: [trip.trip_id] })
172
+ }
173
+ }
174
+
175
+ // Load routes into a map for lookup
176
+ const routeMap = new Map<string, Awaited<ReturnType<typeof routeToTags>>>()
177
+ for await (const route of archive.iter("routes.txt")) {
178
+ routeMap.set(route.route_id, routeToTags(route))
179
+ }
180
+
181
+ // Process unique (shape, route) pairs - one way per combination
182
+ let count = 0
183
+ for (const [key, { tripIds }] of shapeRouteToTrips) {
184
+ const [shapeId, routeId] = key.split(":")
185
+ const routeTags = routeMap.get(routeId!)
186
+ if (!routeTags) continue
187
+
188
+ const shapePoints = shapeMap.get(shapeId!)
189
+ if (!shapePoints || shapePoints.length < 2) {
190
+ this.onProgress(
191
+ progressEvent(`No shape data found for shape ${shapeId}`, "error"),
192
+ )
193
+ continue
194
+ }
195
+
196
+ // Build tags with route info and all trip IDs for this route
197
+ const tags: OsmTags = {
198
+ ...routeTags,
199
+ "gtfs:shape_id": shapeId!,
200
+ "gtfs:trip_ids": tripIds.join(";"),
201
+ "gtfs:trip_count": tripIds.length,
202
+ }
203
+
204
+ // Create way from shape points
205
+ this.createWayFromShape(tags, shapePoints)
206
+ count++
207
+
208
+ if (count % 100 === 0) {
209
+ this.onProgress(
210
+ progressEvent(`Processed ${count} shape-route pairs...`),
211
+ )
212
+ }
213
+ }
214
+
215
+ this.onProgress(progressEvent(`Added ${count} shape-route pairs as ways`))
216
+ }
217
+
218
+ /**
219
+ * Create a way from shape points.
220
+ */
221
+ private createWayFromShape(tags: OsmTags, shapePoints: GtfsShapePoint[]) {
222
+ const nodeRefs: number[] = []
223
+
224
+ for (const point of shapePoints) {
225
+ const lat = Number.parseFloat(point.shape_pt_lat)
226
+ const lon = Number.parseFloat(point.shape_pt_lon)
227
+
228
+ if (Number.isNaN(lat) || Number.isNaN(lon)) continue
229
+
230
+ // Create a node for this shape point
231
+ const nodeId = this.nextNodeId--
232
+ this.osm.nodes.addNode({
233
+ id: nodeId,
234
+ lat,
235
+ lon,
236
+ })
237
+ nodeRefs.push(nodeId)
238
+ }
239
+
240
+ if (nodeRefs.length >= 2) {
241
+ const wayId = this.nextWayId--
242
+ this.osm.ways.addWay({
243
+ id: wayId,
244
+ refs: nodeRefs,
245
+ tags,
246
+ })
247
+ }
248
+ }
249
+
250
+ /**
251
+ * Build the OSM index with all entities.
252
+ */
253
+ buildOsm(): Osm {
254
+ this.onProgress(progressEvent("Building indexes..."))
255
+ this.osm.buildIndexes()
256
+ this.osm.buildSpatialIndexes()
257
+ return this.osm
258
+ }
259
+ }