GdzieWjade/scripts/build-transit.mjs

136 lines
6.8 KiB
JavaScript
Raw Normal View History

2026-10-03 19:14:41 +02:00
// Builds public/data/kk-transit.json: the part of the ZTP Kraków timetable (GTFS) that touches the snapshot area.
// Run after `pnpm data:build` (it reads the stops in public/data/kk-snapshot.json). Uses the GTFS zips cached in scripts/.cache.
// Data © ZTP Kraków / MPK. Licence of the feed: confirm at otwartedane.um.krakow.pl before reuse.
import { readFile, writeFile } from 'node:fs/promises';
import { execFileSync } from 'node:child_process';
import { existsSync } from 'node:fs';
const snap = JSON.parse(await readFile('public/data/kk-snapshot.json', 'utf8'));
const FEEDS = [{ mode: 'bus', zip: 'scripts/.cache/gtfs-bus.zip' }, { mode: 'tram', zip: 'scripts/.cache/gtfs-tram.zip' }];
function parseCsv(text) {
const rows = [];
let row = [], cur = '', q = false;
for (let i = 0; i < text.length; i++) {
const c = text[i];
if (q) {
if (c === '"' && text[i + 1] === '"') { cur += '"'; i++; }
else if (c === '"') q = false;
else cur += c;
} else if (c === '"') q = true;
else if (c === ',') { row.push(cur); cur = ''; }
else if (c === '\n' || c === '\r') {
if (c === '\r' && text[i + 1] === '\n') i++;
row.push(cur); cur = '';
if (row.length > 1 || row[0] !== '') rows.push(row);
row = [];
} else cur += c;
}
if (cur || row.length) { row.push(cur); rows.push(row); }
const [head, ...body] = rows;
return body.filter((r) => r.length === head.length).map((r) => Object.fromEntries(head.map((h, i) => [h.replace(/^/, ''), r[i]])));
}
const read = (zip, f) => execFileSync('unzip', ['-p', zip, f], { maxBuffer: 512 * 1024 * 1024 }).toString('utf8');
const minutes = (hms) => { const [h, m, s] = hms.split(':').map(Number); return h * 60 + m + Math.round((s || 0) / 60); };
const dateInt = (s) => Number(s);
const routes = []; // [{ id, short, mode, color }]
const routeIdx = new Map();
const services = []; // sorted yyyymmdd ints
const patterns = []; // see format below
const patternIdx = new Map();
const stopsUsed = new Set();
let tripCount = 0;
for (const feed of FEEDS) {
if (!existsSync(feed.zip)) throw new Error(`missing ${feed.zip}: run pnpm data:build first`);
const prefix = `z${feed.mode[0]}`;
const areaStops = new Set(snap.places.filter((p) => p.cat === 'transit' && p.id.startsWith(prefix)).map((p) => p.id.slice(2)));
const routeRows = parseCsv(read(feed.zip, 'routes.txt'));
const routeInfo = new Map(routeRows.map((r) => [r.route_id, { short: r.route_short_name, color: r.route_color && r.route_color !== 'FFFFFF' ? r.route_color : '' }]));
// service_id -> dates
const cal = parseCsv(read(feed.zip, 'calendar.txt'));
const exc = parseCsv(read(feed.zip, 'calendar_dates.txt'));
const svcDates = new Map();
const add = (id, d) => { (svcDates.get(id) ?? svcDates.set(id, new Set()).get(id)).add(d); };
const dayNames = ['sunday', 'monday', 'tuesday', 'wednesday', 'thursday', 'friday', 'saturday'];
for (const c of cal) {
const start = dateInt(c.start_date), end = dateInt(c.end_date);
if (!dayNames.some((d) => c[d] === '1')) { svcDates.get(c.service_id) ?? svcDates.set(c.service_id, new Set()); continue; }
for (let t = Date.UTC(+String(start).slice(0, 4), +String(start).slice(4, 6) - 1, +String(start).slice(6, 8)); ; t += 864e5) {
const dt = new Date(t); const d = dt.getUTCFullYear() * 10000 + (dt.getUTCMonth() + 1) * 100 + dt.getUTCDate();
if (d > end) break;
if (c[dayNames[dt.getUTCDay()]] === '1') add(c.service_id, d);
}
}
for (const e of exc) {
if (e.exception_type === '1') add(e.service_id, dateInt(e.date));
else if (e.exception_type === '2') svcDates.get(e.service_id)?.delete(dateInt(e.date));
}
const svcIdx = new Map();
for (const [id, set] of svcDates) svcIdx.set(id, services.push([...set].sort((a, b) => a - b)) - 1);
// trips
const trips = new Map();
for (const t of parseCsv(read(feed.zip, 'trips.txt'))) trips.set(t.trip_id, t);
// stop_times: keep only rows at area stops
const byTrip = new Map();
const lines = read(feed.zip, 'stop_times.txt').split(/\r\n|\r|\n/);
for (let i = 1; i < lines.length; i++) {
const line = lines[i];
if (!line) continue;
const c0 = line.indexOf(','), c1 = line.indexOf(',', c0 + 1), c2 = line.indexOf(',', c1 + 1), c3 = line.indexOf(',', c2 + 1), c4 = line.indexOf(',', c3 + 1);
const stop = line.slice(c2 + 1, c3).replace(/"/g, '');
if (!areaStops.has(stop)) continue;
const tripId = line.slice(0, c0).replace(/"/g, '');
const seq = Number(line.slice(c3 + 1, c4));
const arr = minutes(line.slice(c0 + 1, c1)), dep = minutes(line.slice(c1 + 1, c2));
(byTrip.get(tripId) ?? byTrip.set(tripId, []).get(tripId)).push({ stop, seq, arr, dep });
}
for (const [tripId, rows] of byTrip) {
const trip = trips.get(tripId);
if (!trip || svcIdx.get(trip.service_id) === undefined) continue;
rows.sort((a, b) => a.seq - b.seq);
const info = routeInfo.get(trip.route_id);
if (!info) continue;
const rk = `${feed.mode}:${info.short}`;
let r = routeIdx.get(rk);
if (r === undefined) { r = routes.push({ short: info.short, mode: feed.mode, color: info.color }) - 1; routeIdx.set(rk, r); }
const stops = rows.map((x) => `${prefix}${x.stop}`);
stops.forEach((s) => stopsUsed.add(s));
const t0 = rows[0].dep;
const offs = rows.map((x) => x.arr - t0).concat(rows.map((x) => x.dep - t0)); // [arrivals..., departures...]
const key = `${r}|${trip.trip_headsign}|${stops.join(',')}`;
let pi = patternIdx.get(key);
if (pi === undefined) {
pi = patterns.push({ r, h: trip.trip_headsign, s: stops, o: [], t: [], _ov: new Map() }) - 1;
patternIdx.set(key, pi);
}
const p = patterns[pi];
const ok = offs.join(',');
let vi = p._ov.get(ok);
if (vi === undefined) { vi = p.o.push(offs) - 1; p._ov.set(ok, vi); }
// wheelchair_accessible: 1 = low-floor vehicle, 2 = not accessible, 0 = unknown
p.t.push([svcIdx.get(trip.service_id), t0, vi, Number(trip.wheelchair_accessible) || 0, tripId]);
tripCount++;
}
}
for (const p of patterns) { p.t.sort((a, b) => a[1] - b[1]); delete p._ov; }
const out = {
meta: {
builtAt: new Date().toISOString(),
source: 'ZTP Kraków GTFS',
feeds: FEEDS.map((f) => ({ mode: f.mode, version: snap.meta.cityData.feedDates.find((d) => d.mode === f.mode)?.version ?? null })),
license: 'verify at otwartedane.um.krakow.pl before reuse',
note: 'Pattern t: [serviceIdx, firstStopDepartureMin, offsetVariantIdx, wheelchair(0 unknown/1 low-floor/2 no), tripId]; o: arrivals then departures, minutes relative to t0; services: yyyymmdd lists.',
},
routes, services, patterns,
};
await writeFile('public/data/kk-transit.json', JSON.stringify(out));
console.log({ routes: routes.length, services: services.length, patterns: patterns.length, trips: tripCount, stops: stopsUsed.size });