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 [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' } }