@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,138 @@
1
+ /**
2
+ * Lazy GTFS archive parser with streaming CSV support.
3
+ *
4
+ * Only parses CSV files when they are accessed, not upfront.
5
+ * Uses @std/csv CsvParseStream for true line-by-line streaming.
6
+ *
7
+ * @module
8
+ */
9
+
10
+ import { CsvParseStream } from "@std/csv/parse-stream"
11
+ import { unzip, type ZipItem } from "but-unzip"
12
+ import type {
13
+ GtfsAgency,
14
+ GtfsRoute,
15
+ GtfsShapePoint,
16
+ GtfsStop,
17
+ GtfsStopTime,
18
+ GtfsTrip,
19
+ } from "./types"
20
+ import { bytesToTextStream } from "./utils"
21
+
22
+ /**
23
+ * Map of GTFS filenames to their record types.
24
+ */
25
+ export interface GtfsFileTypeMap {
26
+ "agency.txt": GtfsAgency
27
+ "stops.txt": GtfsStop
28
+ "routes.txt": GtfsRoute
29
+ "trips.txt": GtfsTrip
30
+ "stop_times.txt": GtfsStopTime
31
+ "shapes.txt": GtfsShapePoint
32
+ }
33
+
34
+ /** Valid GTFS filenames that can be parsed. */
35
+ export type GtfsFileName = keyof GtfsFileTypeMap
36
+
37
+ /**
38
+ * Lazy GTFS archive that only parses files on demand.
39
+ *
40
+ * Files are read from the zip and parsed only when their
41
+ * corresponding getter is called for the first time.
42
+ * Streaming iterators parse CSV line-by-line without loading
43
+ * the entire file into memory.
44
+ */
45
+ export class GtfsArchive {
46
+ private entries: Map<string, ZipItem>
47
+
48
+ private constructor(entries: Map<string, ZipItem>) {
49
+ this.entries = entries
50
+ }
51
+
52
+ /**
53
+ * Create a GtfsArchive from zip data.
54
+ */
55
+ static fromZip(zipData: ArrayBuffer | Uint8Array): GtfsArchive {
56
+ const bytes =
57
+ zipData instanceof Uint8Array ? zipData : new Uint8Array(zipData)
58
+ const items = unzip(bytes)
59
+
60
+ const entries = new Map<string, ZipItem>()
61
+ for (const item of items) {
62
+ // Remove directory prefix and store by filename
63
+ const name = item.filename.replace(/^.*\//, "")
64
+ if (name.endsWith(".txt")) {
65
+ entries.set(name, item)
66
+ }
67
+ }
68
+
69
+ return new GtfsArchive(entries)
70
+ }
71
+
72
+ /**
73
+ * Check if a file exists in the archive.
74
+ */
75
+ hasFile(filename: string): boolean {
76
+ return this.entries.has(filename)
77
+ }
78
+
79
+ /**
80
+ * List all files in the archive.
81
+ */
82
+ listFiles(): string[] {
83
+ return Array.from(this.entries.keys())
84
+ }
85
+
86
+ /**
87
+ * Get a readable stream of bytes for a file.
88
+ */
89
+ private async getFileBytes(filename: string): Promise<Uint8Array | null> {
90
+ const entry = this.entries.get(filename)
91
+ if (!entry) return null
92
+
93
+ const data = entry.read()
94
+ return data instanceof Promise ? await data : data
95
+ }
96
+
97
+ /**
98
+ * Stream parse a CSV file, yielding typed records one at a time.
99
+ *
100
+ * The return type is automatically inferred based on the filename:
101
+ * - `"stops.txt"` → `AsyncGenerator<GtfsStop>`
102
+ * - `"routes.txt"` → `AsyncGenerator<GtfsRoute>`
103
+ * - `"shapes.txt"` → `AsyncGenerator<GtfsShapePoint>`
104
+ * - etc.
105
+ *
106
+ * @param filename - The GTFS filename to parse (e.g., "stops.txt")
107
+ * @returns An async generator yielding typed records
108
+ *
109
+ * @example
110
+ * ```ts
111
+ * for await (const stop of archive.iter("stops.txt")) {
112
+ * console.log(stop.stop_name) // TypeScript knows this is GtfsStop
113
+ * }
114
+ * ```
115
+ */
116
+ async *iter<F extends GtfsFileName>(
117
+ filename: F,
118
+ ): AsyncGenerator<GtfsFileTypeMap[F], void, unknown> {
119
+ const bytes = await this.getFileBytes(filename)
120
+ if (!bytes) return
121
+
122
+ const textStream = bytesToTextStream(bytes)
123
+ const csvStream = textStream.pipeThrough(
124
+ new CsvParseStream({ skipFirstRow: true }),
125
+ )
126
+
127
+ const reader = csvStream.getReader()
128
+ try {
129
+ while (true) {
130
+ const { value, done } = await reader.read()
131
+ if (done) break
132
+ yield value as unknown as GtfsFileTypeMap[F]
133
+ }
134
+ } finally {
135
+ reader.releaseLock()
136
+ }
137
+ }
138
+ }
package/src/index.ts ADDED
@@ -0,0 +1,54 @@
1
+ /**
2
+ * @osmix/gtfs - Convert GTFS transit feeds to OSM format.
3
+ *
4
+ * Parses zipped GTFS files lazily and converts transit data to OpenStreetMap entities:
5
+ * - **Stops** become **Nodes** with public transport tags
6
+ * - **Routes** become **Ways** with shape geometry
7
+ *
8
+ * Files are only parsed when needed, not upfront.
9
+ *
10
+ * @example
11
+ * ```ts
12
+ * import { fromGtfs } from "@osmix/gtfs"
13
+ *
14
+ * const response = await fetch("https://example.com/gtfs.zip")
15
+ * const zipData = await response.arrayBuffer()
16
+ * const osm = await fromGtfs(zipData, { id: "transit" })
17
+ *
18
+ * console.log(`Imported ${osm.nodes.size} stops and ${osm.ways.size} routes`)
19
+ * ```
20
+ *
21
+ * @example Using GtfsArchive directly for custom processing
22
+ * ```ts
23
+ * import { GtfsArchive } from "@osmix/gtfs"
24
+ *
25
+ * const archive = GtfsArchive.fromZip(zipData)
26
+ *
27
+ * // Only parse stops - other files remain unread
28
+ * for await (const stop of archive.iterStops()) {
29
+ * console.log(stop.stop_name)
30
+ * }
31
+ * ```
32
+ *
33
+ * @module @osmix/gtfs
34
+ */
35
+
36
+ export { fromGtfs, GtfsOsmBuilder } from "./from-gtfs"
37
+ export {
38
+ GtfsArchive,
39
+ type GtfsFileName,
40
+ type GtfsFileTypeMap,
41
+ } from "./gtfs-archive"
42
+ export {
43
+ type GtfsAgency,
44
+ type GtfsConversionOptions,
45
+ type GtfsFeed,
46
+ type GtfsRoute,
47
+ type GtfsShapePoint,
48
+ type GtfsStop,
49
+ type GtfsStopTime,
50
+ type GtfsTrip,
51
+ routeTypeToOsmRoute,
52
+ wheelchairBoardingToOsm,
53
+ } from "./types"
54
+ export { isGtfsZip } from "./utils"
package/src/types.ts ADDED
@@ -0,0 +1,184 @@
1
+ /**
2
+ * GTFS (General Transit Feed Specification) type definitions.
3
+ *
4
+ * @module
5
+ */
6
+
7
+ /**
8
+ * GTFS stop from stops.txt.
9
+ * Represents a transit stop or station.
10
+ */
11
+ export interface GtfsStop {
12
+ stop_id: string
13
+ stop_code?: string
14
+ stop_name: string
15
+ stop_desc?: string
16
+ stop_lat: string
17
+ stop_lon: string
18
+ zone_id?: string
19
+ stop_url?: string
20
+ /** 0 = stop, 1 = station, 2 = entrance/exit, 3 = generic node, 4 = boarding area */
21
+ location_type?: string
22
+ parent_station?: string
23
+ stop_timezone?: string
24
+ /** 0 = no info, 1 = accessible, 2 = not accessible */
25
+ wheelchair_boarding?: string
26
+ level_id?: string
27
+ platform_code?: string
28
+ }
29
+
30
+ /**
31
+ * GTFS route from routes.txt.
32
+ * Represents a transit route/line.
33
+ */
34
+ export interface GtfsRoute {
35
+ route_id: string
36
+ agency_id?: string
37
+ route_short_name?: string
38
+ route_long_name?: string
39
+ route_desc?: string
40
+ /**
41
+ * Route type:
42
+ * 0 = Tram, 1 = Subway, 2 = Rail, 3 = Bus, 4 = Ferry,
43
+ * 5 = Cable tram, 6 = Aerial lift, 7 = Funicular,
44
+ * 11 = Trolleybus, 12 = Monorail
45
+ */
46
+ route_type: string
47
+ route_url?: string
48
+ route_color?: string
49
+ route_text_color?: string
50
+ route_sort_order?: string
51
+ continuous_pickup?: string
52
+ continuous_drop_off?: string
53
+ network_id?: string
54
+ }
55
+
56
+ /**
57
+ * GTFS shape point from shapes.txt.
58
+ * Defines the geographic path of a route.
59
+ */
60
+ export interface GtfsShapePoint {
61
+ shape_id: string
62
+ shape_pt_lat: string
63
+ shape_pt_lon: string
64
+ shape_pt_sequence: string
65
+ shape_dist_traveled?: string
66
+ }
67
+
68
+ /**
69
+ * GTFS trip from trips.txt.
70
+ * Represents a specific trip on a route.
71
+ */
72
+ export interface GtfsTrip {
73
+ trip_id: string
74
+ route_id: string
75
+ service_id: string
76
+ trip_headsign?: string
77
+ trip_short_name?: string
78
+ direction_id?: string
79
+ block_id?: string
80
+ shape_id?: string
81
+ wheelchair_accessible?: string
82
+ bikes_allowed?: string
83
+ }
84
+
85
+ /**
86
+ * GTFS stop time from stop_times.txt.
87
+ * Links trips to stops with timing info.
88
+ */
89
+ export interface GtfsStopTime {
90
+ trip_id: string
91
+ arrival_time?: string
92
+ departure_time?: string
93
+ stop_id: string
94
+ stop_sequence: string
95
+ stop_headsign?: string
96
+ pickup_type?: string
97
+ drop_off_type?: string
98
+ continuous_pickup?: string
99
+ continuous_drop_off?: string
100
+ shape_dist_traveled?: string
101
+ timepoint?: string
102
+ }
103
+
104
+ /**
105
+ * GTFS agency from agency.txt.
106
+ */
107
+ export interface GtfsAgency {
108
+ agency_id?: string
109
+ agency_name: string
110
+ agency_url: string
111
+ agency_timezone: string
112
+ agency_lang?: string
113
+ agency_phone?: string
114
+ agency_fare_url?: string
115
+ agency_email?: string
116
+ }
117
+
118
+ /**
119
+ * Parsed GTFS feed with all relevant files.
120
+ */
121
+ export interface GtfsFeed {
122
+ agencies: GtfsAgency[]
123
+ stops: GtfsStop[]
124
+ routes: GtfsRoute[]
125
+ trips: GtfsTrip[]
126
+ stopTimes: GtfsStopTime[]
127
+ shapes: GtfsShapePoint[]
128
+ }
129
+
130
+ /**
131
+ * Options for GTFS to OSM conversion.
132
+ */
133
+ export interface GtfsConversionOptions {
134
+ /** Whether to include stops as nodes. Default: true */
135
+ includeStops?: boolean
136
+ /** Whether to include routes as ways. Default: true */
137
+ includeRoutes?: boolean
138
+ }
139
+
140
+ /**
141
+ * Map GTFS route_type to OSM route tag value.
142
+ */
143
+ export function routeTypeToOsmRoute(routeType: string): string {
144
+ switch (routeType) {
145
+ case "0":
146
+ return "tram"
147
+ case "1":
148
+ return "subway"
149
+ case "2":
150
+ return "train"
151
+ case "3":
152
+ return "bus"
153
+ case "4":
154
+ return "ferry"
155
+ case "5":
156
+ return "tram" // Cable tram
157
+ case "6":
158
+ return "aerialway"
159
+ case "7":
160
+ return "funicular"
161
+ case "11":
162
+ return "trolleybus"
163
+ case "12":
164
+ return "train" // Monorail
165
+ default:
166
+ return "bus"
167
+ }
168
+ }
169
+
170
+ /**
171
+ * Map GTFS wheelchair_boarding to OSM wheelchair tag value.
172
+ */
173
+ export function wheelchairBoardingToOsm(
174
+ value: string | undefined,
175
+ ): string | undefined {
176
+ switch (value) {
177
+ case "1":
178
+ return "yes"
179
+ case "2":
180
+ return "no"
181
+ default:
182
+ return undefined
183
+ }
184
+ }
package/src/utils.ts ADDED
@@ -0,0 +1,226 @@
1
+ import { normalizeHexColor } from "@osmix/shared/color"
2
+ import type { OsmTags } from "@osmix/shared/types"
3
+ import {
4
+ type GtfsRoute,
5
+ type GtfsStop,
6
+ type GtfsTrip,
7
+ routeTypeToOsmRoute,
8
+ wheelchairBoardingToOsm,
9
+ } from "./types"
10
+
11
+ /**
12
+ * Check if a ZIP file (as bytes) is a GTFS archive by looking for characteristic GTFS files.
13
+ * GTFS archives must contain at least agency.txt, stops.txt, routes.txt, trips.txt,
14
+ * and stop_times.txt according to the GTFS specification.
15
+ */
16
+ export function isGtfsZip(bytes: Uint8Array): boolean {
17
+ // GTFS required files per the spec
18
+ const requiredGtfsFiles = [
19
+ "agency.txt",
20
+ "stops.txt",
21
+ "routes.txt",
22
+ "trips.txt",
23
+ "stop_times.txt",
24
+ ]
25
+
26
+ // Find filenames in the ZIP by scanning for the local file headers
27
+ // ZIP local file header signature: 0x04034b50 (little-endian: 50 4b 03 04)
28
+ const foundFiles = new Set<string>()
29
+ const decoder = new TextDecoder()
30
+ let pos = 0
31
+
32
+ while (pos < bytes.length - 30) {
33
+ // Check for ZIP local file header signature
34
+ const isLocalHeader =
35
+ bytes[pos] === 0x50 &&
36
+ bytes[pos + 1] === 0x4b &&
37
+ bytes[pos + 2] === 0x03 &&
38
+ bytes[pos + 3] === 0x04
39
+
40
+ if (isLocalHeader) {
41
+ // Read filename length at offset 26-27 (little-endian)
42
+ const nameLen = (bytes[pos + 26] ?? 0) | ((bytes[pos + 27] ?? 0) << 8)
43
+ // Read extra field length at offset 28-29 (little-endian)
44
+ const extraLen = (bytes[pos + 28] ?? 0) | ((bytes[pos + 29] ?? 0) << 8)
45
+
46
+ // Defensive bounds check
47
+ if (nameLen < 0 || extraLen < 0) break
48
+ if (pos + 30 + nameLen + extraLen > bytes.length) break
49
+
50
+ // General purpose bit flag at offset 6-7 (little-endian)
51
+ // Bit 3 indicates the presence of a data descriptor, in which case
52
+ // the compressed size fields in the local header are zero and the
53
+ // actual sizes follow the compressed data.
54
+ const flags = (bytes[pos + 6] ?? 0) | ((bytes[pos + 7] ?? 0) << 8)
55
+ const hasDataDescriptor = (flags & 0x0008) !== 0
56
+
57
+ // Extract filename (starts at offset 30)
58
+ const nameBytes = bytes.slice(pos + 30, pos + 30 + nameLen)
59
+ const filename = decoder.decode(nameBytes)
60
+
61
+ // Normalize path - extract just the filename part
62
+ const basename = filename.replace(/^.*\//, "").toLowerCase()
63
+ if (basename) {
64
+ foundFiles.add(basename)
65
+ }
66
+
67
+ if (!hasDataDescriptor) {
68
+ // Read compressed size at offset 18-21 (little-endian).
69
+ // Use >>> 0 to ensure unsigned 32-bit interpretation, since
70
+ // JavaScript bitwise operations return signed 32-bit integers.
71
+ // Without this, files >= 2GB would produce negative values.
72
+ const compSize =
73
+ ((bytes[pos + 18] ?? 0) |
74
+ ((bytes[pos + 19] ?? 0) << 8) |
75
+ ((bytes[pos + 20] ?? 0) << 16) |
76
+ ((bytes[pos + 21] ?? 0) << 24)) >>>
77
+ 0
78
+
79
+ // Move to next entry using the known compressed size
80
+ pos += 30 + nameLen + extraLen + compSize
81
+ } else {
82
+ // When a data descriptor is present, the compressed size is not
83
+ // available in the local header. Skip past the header, filename,
84
+ // and extra fields, then scan forward for the next local header
85
+ // signature. This avoids getting stuck at the same position when
86
+ // the compressed size field is zero.
87
+ pos += 30 + nameLen + extraLen
88
+
89
+ while (pos < bytes.length - 3) {
90
+ const nextIsHeader =
91
+ bytes[pos] === 0x50 &&
92
+ bytes[pos + 1] === 0x4b &&
93
+ bytes[pos + 2] === 0x03 &&
94
+ bytes[pos + 3] === 0x04
95
+ if (nextIsHeader) break
96
+ pos++
97
+ }
98
+ }
99
+ } else {
100
+ pos++
101
+ }
102
+ }
103
+
104
+ // Check if all required GTFS files are present
105
+ return requiredGtfsFiles.every((f) => foundFiles.has(f))
106
+ }
107
+
108
+ /**
109
+ * Convert a GTFS stop to OSM tags.
110
+ */
111
+ export function stopToTags(stop: GtfsStop): OsmTags {
112
+ const tags: OsmTags = {
113
+ public_transport: "platform",
114
+ }
115
+
116
+ if (stop.stop_name) tags["name"] = stop.stop_name
117
+ if (stop.stop_id) tags["ref"] = stop.stop_id
118
+ if (stop.stop_code) tags["ref:gtfs:stop_code"] = stop.stop_code
119
+ if (stop.stop_desc) tags["description"] = stop.stop_desc
120
+ if (stop.stop_url) tags["website"] = stop.stop_url
121
+ if (stop.platform_code) tags["ref:platform"] = stop.platform_code
122
+
123
+ // Location type determines more specific tagging
124
+ const locationType = stop.location_type ?? "0"
125
+ switch (locationType) {
126
+ case "1":
127
+ tags["public_transport"] = "station"
128
+ break
129
+ case "2":
130
+ // Entrances are not platforms - remove the default and use railway tag
131
+ delete tags["public_transport"]
132
+ tags["railway"] = "subway_entrance"
133
+ break
134
+ case "3":
135
+ // Generic node - keep as platform
136
+ break
137
+ case "4":
138
+ tags["public_transport"] = "platform"
139
+ break
140
+ }
141
+
142
+ // Wheelchair accessibility
143
+ const wheelchair = wheelchairBoardingToOsm(stop.wheelchair_boarding)
144
+ if (wheelchair) tags["wheelchair"] = wheelchair
145
+
146
+ return tags
147
+ }
148
+
149
+ /**
150
+ * Convert a GTFS route to OSM tags.
151
+ */
152
+ export function routeToTags(route: GtfsRoute): OsmTags {
153
+ const tags: OsmTags = {
154
+ route: routeTypeToOsmRoute(route.route_type),
155
+ }
156
+
157
+ // Use long name if available, otherwise short name
158
+ if (route.route_long_name) {
159
+ tags["name"] = route.route_long_name
160
+ } else if (route.route_short_name) {
161
+ tags["name"] = route.route_short_name
162
+ }
163
+
164
+ if (route.route_short_name) tags["ref"] = route.route_short_name
165
+ if (route.route_id) tags["ref:gtfs:route_id"] = route.route_id
166
+ if (route.route_desc) tags["description"] = route.route_desc
167
+ if (route.route_url) tags["website"] = route.route_url
168
+
169
+ // Route color (validate and normalize hex color)
170
+ const color = normalizeHexColor(route.route_color)
171
+ if (color) tags["color"] = color
172
+
173
+ const textColor = normalizeHexColor(route.route_text_color)
174
+ if (textColor) tags["text_color"] = textColor
175
+
176
+ // Route type as additional tag
177
+ tags["gtfs:route_type"] = route.route_type
178
+
179
+ return tags
180
+ }
181
+
182
+ /**
183
+ * Convert a GTFS trip to OSM tags.
184
+ */
185
+ export function tripToTags(trip: GtfsTrip): OsmTags {
186
+ const tags: OsmTags = {}
187
+ tags["ref:gtfs:trip_id"] = trip.trip_id
188
+ if (trip.service_id) tags["ref:gtfs:service_id"] = trip.service_id
189
+ if (trip.trip_headsign) tags["ref:gtfs:trip_headsign"] = trip.trip_headsign
190
+ if (trip.trip_short_name)
191
+ tags["ref:gtfs:trip_short_name"] = trip.trip_short_name
192
+ if (trip.direction_id) tags["ref:gtfs:direction_id"] = trip.direction_id
193
+ if (trip.block_id) tags["ref:gtfs:block_id"] = trip.block_id
194
+ if (trip.shape_id) tags["ref:gtfs:shape_id"] = trip.shape_id
195
+ if (trip.wheelchair_accessible)
196
+ tags["ref:gtfs:wheelchair_accessible"] = trip.wheelchair_accessible
197
+ if (trip.bikes_allowed) tags["ref:gtfs:bikes_allowed"] = trip.bikes_allowed
198
+
199
+ return tags
200
+ }
201
+
202
+ /**
203
+ * Create a ReadableStream of text from file bytes.
204
+ */
205
+ export function bytesToTextStream(bytes: Uint8Array): ReadableStream<string> {
206
+ const decoder = new TextDecoder()
207
+ let offset = 0
208
+ const chunkSize = 64 * 1024 // 64KB chunks
209
+
210
+ return new ReadableStream<string>({
211
+ pull(controller) {
212
+ if (offset >= bytes.length) {
213
+ controller.close()
214
+ return
215
+ }
216
+
217
+ const end = Math.min(offset + chunkSize, bytes.length)
218
+ const chunk = bytes.subarray(offset, end)
219
+ offset = end
220
+
221
+ controller.enqueue(
222
+ decoder.decode(chunk, { stream: offset < bytes.length }),
223
+ )
224
+ },
225
+ })
226
+ }