@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.
- package/CHANGELOG.md +9 -0
- package/README.md +200 -0
- package/dist/from-gtfs.d.ts +76 -0
- package/dist/from-gtfs.d.ts.map +1 -0
- package/dist/from-gtfs.js +211 -0
- package/dist/from-gtfs.js.map +1 -0
- package/dist/gtfs-archive.d.ts +71 -0
- package/dist/gtfs-archive.d.ts.map +1 -0
- package/dist/gtfs-archive.js +102 -0
- package/dist/gtfs-archive.js.map +1 -0
- package/dist/index.d.ts +39 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +39 -0
- package/dist/index.js.map +1 -0
- package/dist/src/from-gtfs.d.ts +76 -0
- package/dist/src/from-gtfs.d.ts.map +1 -0
- package/dist/src/from-gtfs.js +211 -0
- package/dist/src/from-gtfs.js.map +1 -0
- package/dist/src/gtfs-archive.d.ts +71 -0
- package/dist/src/gtfs-archive.d.ts.map +1 -0
- package/dist/src/gtfs-archive.js +102 -0
- package/dist/src/gtfs-archive.js.map +1 -0
- package/dist/src/index.d.ts +39 -0
- package/dist/src/index.d.ts.map +1 -0
- package/dist/src/index.js +39 -0
- package/dist/src/index.js.map +1 -0
- package/dist/src/types.d.ts +139 -0
- package/dist/src/types.d.ts.map +1 -0
- package/dist/src/types.js +48 -0
- package/dist/src/types.js.map +1 -0
- package/dist/src/utils.d.ts +25 -0
- package/dist/src/utils.d.ts.map +1 -0
- package/dist/src/utils.js +210 -0
- package/dist/src/utils.js.map +1 -0
- package/dist/test/from-gtfs.test.d.ts +2 -0
- package/dist/test/from-gtfs.test.d.ts.map +1 -0
- package/dist/test/from-gtfs.test.js +389 -0
- package/dist/test/from-gtfs.test.js.map +1 -0
- package/dist/test/helpers.d.ts +14 -0
- package/dist/test/helpers.d.ts.map +1 -0
- package/dist/test/helpers.js +84 -0
- package/dist/test/helpers.js.map +1 -0
- package/dist/types.d.ts +139 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +48 -0
- package/dist/types.js.map +1 -0
- package/dist/utils.d.ts +25 -0
- package/dist/utils.d.ts.map +1 -0
- package/dist/utils.js +211 -0
- package/dist/utils.js.map +1 -0
- package/package.json +53 -0
- package/src/from-gtfs.ts +259 -0
- package/src/gtfs-archive.ts +138 -0
- package/src/index.ts +54 -0
- package/src/types.ts +184 -0
- package/src/utils.ts +226 -0
- package/test/from-gtfs.test.ts +501 -0
- package/test/helpers.ts +118 -0
- package/tsconfig.build.json +5 -0
- 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
|
+
}
|