GdzieWjade/scripts/build-transit.mjs

135 lines
6.8 KiB
JavaScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// 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 });