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