132 lines
3.6 KiB
JavaScript
132 lines
3.6 KiB
JavaScript
import { createReadStream, mkdirSync, readFileSync, writeFileSync } from 'node:fs'
|
|
import { dirname, join } from 'node:path'
|
|
import { fileURLToPath } from 'node:url'
|
|
import { createInterface } from 'node:readline'
|
|
|
|
export async function buildStopIndex(gtfsDir, outputPath = 'public/stops.json') {
|
|
const stops = readCsv(join(gtfsDir, 'stops.txt'))
|
|
const routes = readCsv(join(gtfsDir, 'routes.txt'))
|
|
const trips = readCsv(join(gtfsDir, 'trips.txt'))
|
|
const routeById = new Map(routes.map((route) => [route.route_id, route]))
|
|
const tripRouteById = new Map(trips.map((trip) => [trip.trip_id, trip.route_id]))
|
|
const routesByStop = new Map()
|
|
const modesByStop = new Map()
|
|
|
|
await eachCsvRow(join(gtfsDir, 'stop_times.txt'), (row) => {
|
|
const stopId = row.stop_id
|
|
const routeId = tripRouteById.get(row.trip_id)
|
|
if (!stopId || !routeId) {
|
|
return
|
|
}
|
|
|
|
const route = routeById.get(routeId)
|
|
const routeName = route?.route_short_name || route?.route_long_name || routeId
|
|
pushUnique(routesByStop, stopId, routeName)
|
|
pushUnique(modesByStop, stopId, modeFromRouteType(route?.route_type))
|
|
})
|
|
|
|
const index = stops
|
|
.map((stop) => ({
|
|
id: stop.stop_id,
|
|
code: stop.stop_code || stop.stop_id,
|
|
name: stop.stop_name,
|
|
lat: Number(stop.stop_lat),
|
|
lon: Number(stop.stop_lon),
|
|
modes: modesByStop.get(stop.stop_id) || ['unknown'],
|
|
routes: routesByStop.get(stop.stop_id) || [],
|
|
}))
|
|
.filter((stop) => stop.id && stop.name && Number.isFinite(stop.lat) && Number.isFinite(stop.lon))
|
|
|
|
mkdirSync(dirname(outputPath), { recursive: true })
|
|
writeFileSync(outputPath, `${JSON.stringify({ stops: index })}\n`)
|
|
return index.length
|
|
}
|
|
|
|
if (process.argv[1] === fileURLToPath(import.meta.url)) {
|
|
const [gtfsDir, outputPath = 'public/stops.json'] = process.argv.slice(2)
|
|
|
|
if (!gtfsDir) {
|
|
console.error('Usage: node scripts/build-stop-index.mjs <extracted-gtfs-dir> [output.json]')
|
|
process.exit(1)
|
|
}
|
|
|
|
const count = await buildStopIndex(gtfsDir, outputPath)
|
|
console.log(`Wrote ${count} stops to ${outputPath}`)
|
|
}
|
|
|
|
function readCsv(path) {
|
|
const [headerLine, ...lines] = readFileSync(path, 'utf8').trim().split(/\r?\n/)
|
|
const headers = parseCsvLine(headerLine)
|
|
return lines.map((line) => rowFromValues(headers, parseCsvLine(line)))
|
|
}
|
|
|
|
async function eachCsvRow(path, callback) {
|
|
const reader = createInterface({
|
|
input: createReadStream(path),
|
|
crlfDelay: Infinity,
|
|
})
|
|
|
|
let headers = []
|
|
for await (const line of reader) {
|
|
if (!headers.length) {
|
|
headers = parseCsvLine(line)
|
|
continue
|
|
}
|
|
callback(rowFromValues(headers, parseCsvLine(line)))
|
|
}
|
|
}
|
|
|
|
function rowFromValues(headers, values) {
|
|
return Object.fromEntries(headers.map((header, index) => [header, values[index] ?? '']))
|
|
}
|
|
|
|
function parseCsvLine(line) {
|
|
const values = []
|
|
let value = ''
|
|
let quoted = false
|
|
|
|
for (let index = 0; index < line.length; index += 1) {
|
|
const char = line[index]
|
|
const next = line[index + 1]
|
|
|
|
if (char === '"' && quoted && next === '"') {
|
|
value += '"'
|
|
index += 1
|
|
} else if (char === '"') {
|
|
quoted = !quoted
|
|
} else if (char === ',' && !quoted) {
|
|
values.push(value)
|
|
value = ''
|
|
} else {
|
|
value += char
|
|
}
|
|
}
|
|
|
|
values.push(value)
|
|
return values
|
|
}
|
|
|
|
function pushUnique(map, key, value) {
|
|
if (!value) {
|
|
return
|
|
}
|
|
const values = map.get(key) || []
|
|
if (!values.includes(value)) {
|
|
values.push(value)
|
|
map.set(key, values)
|
|
}
|
|
}
|
|
|
|
function modeFromRouteType(routeType) {
|
|
switch (String(routeType)) {
|
|
case '0':
|
|
return 'tram'
|
|
case '2':
|
|
return 'rail'
|
|
case '3':
|
|
return 'bus'
|
|
default:
|
|
return 'unknown'
|
|
}
|
|
}
|