135 lines
6.8 KiB
JavaScript
135 lines
6.8 KiB
JavaScript
// 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 });
|