// Builds public/data/kk-snapshot.json from OpenStreetMap via Overpass. // Raw responses are cached in scripts/.cache; delete it to refetch. // Data © OpenStreetMap contributors, ODbL 1.0. import { mkdir, readFile, writeFile } from 'node:fs/promises'; import { existsSync } from 'node:fs'; import { execFileSync } from 'node:child_process'; import { toPlace } from '../src/lib/osmPlace.ts'; const BBOX = [50.046, 19.923, 50.064, 19.956]; // S,W,N,E: Old Town + Kazimierz + Wawel const bb = BBOX.join(','); const OVERPASS = 'https://overpass-api.de/api/interpreter'; const queries = { places: `[out:json][timeout:90]; ( nwr["name"]["amenity"~"^(restaurant|cafe|bar|pub|fast_food|ice_cream|food_court|pharmacy|bank|clinic|doctors|dentist|hospital|post_office|cinema|theatre|library|arts_centre|townhall|community_centre|place_of_worship|university|college|school|police|courthouse)$"](${bb}); nwr["name"]["tourism"~"^(museum|gallery|hotel|hostel|guest_house|apartment|attraction|information)$"](${bb}); nwr["name"]["shop"](${bb}); nwr["name"]["historic"~"^(castle|monument|church|building|city_gate|fort)$"](${bb}); ); out meta center;`, support: `[out:json][timeout:90]; ( nwr["amenity"="toilets"](${bb}); nwr["highway"="elevator"](${bb}); nwr["amenity"="bench"](${bb}); node["highway"="bus_stop"]["name"](${bb}); node["railway"="tram_stop"]["name"](${bb}); ); out meta center;`, net: `[out:json][timeout:120]; way["highway"~"^(footway|pedestrian|path|steps|living_street|residential|service|tertiary|unclassified|track|cycleway)$"](${bb}); out meta; >; out skel qt;`, kerbs: `[out:json][timeout:90]; ( node["kerb"](${bb}); node["highway"="crossing"](${bb}); ); out tags;`, }; async function fetchRaw(name) { const file = `scripts/.cache/${name}.json`; if (existsSync(file)) return JSON.parse(await readFile(file, 'utf8')); for (let attempt = 1; attempt <= 3; attempt++) { const res = await fetch(OVERPASS, { method: 'POST', headers: { 'User-Agent': 'krakow-bez-barier-hackathon/0.1', Accept: 'application/json', 'Content-Type': 'application/x-www-form-urlencoded', }, body: 'data=' + encodeURIComponent(queries[name]), }); if (res.ok) { const text = await res.text(); await writeFile(file, text); return JSON.parse(text); } console.warn(`${name}: HTTP ${res.status}, retry ${attempt}`); await new Promise((r) => setTimeout(r, 8000 * attempt)); } throw new Error(`Overpass failed for ${name}`); } const round = (n) => Math.round(n * 1e6) / 1e6; const day = (ts) => (ts ? ts.slice(0, 10) : null); const [placesRaw, supportRaw, netRaw, kerbRaw] = await Promise.all( Object.keys(queries).map((q) => fetchRaw(q)), ); const places = placesRaw.elements.map((e) => toPlace(e)).filter(Boolean); const toilets = [], elevators = [], benches = [], stops = []; for (const el of supportRaw.elements) { const t = el.tags ?? {}; if (t.amenity === 'toilets') toilets.push(toPlace(el, 'toilets')); else if (t.highway === 'elevator') elevators.push(toPlace(el, 'elevator')); else if (t.amenity === 'bench') { const lat = el.lat ?? el.center?.lat, lon = el.lon ?? el.center?.lon; if (lat != null) benches.push([round(lat), round(lon), day(el.timestamp)]); } else stops.push(toPlace(el, 'transit')); } for (const p of toilets) { const src = supportRaw.elements.find((e) => `${e.type[0]}${e.id}` === p.id).tags; for (const k of ['wheelchair', 'fee', 'opening_hours', 'access', 'toilets:wheelchair', 'changing_table', 'name', 'check_date']) { if (src[k] != null) p.tags[k] = src[k]; } } // ---- city data: ZTP Kraków GTFS (stops + wheelchair_boarding) ---- const GTFS = [ { mode: 'tram', url: 'https://gtfs.ztp.krakow.pl/GTFS_KRK_T.zip' }, { mode: 'bus', url: 'https://gtfs.ztp.krakow.pl/GTFS_KRK_A.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') { row.push(cur.replace(/\r$/, '')); rows.push(row); row = []; cur = ''; } 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(/^\uFEFF/, ''), r[i]]))); } async function loadGtfs({ mode, url }) { const zip = `scripts/.cache/gtfs-${mode}.zip`; if (!existsSync(zip)) { const res = await fetch(url, { headers: { 'User-Agent': 'krakow-bez-barier-hackathon/0.1' } }); if (!res.ok) throw new Error(`GTFS ${mode}: HTTP ${res.status}`); await writeFile(zip, Buffer.from(await res.arrayBuffer())); } const read = (f) => execFileSync('unzip', ['-p', zip, f], { maxBuffer: 256 * 1024 * 1024 }).toString('utf8'); const feed = parseCsv(read('feed_info.txt'))[0]; const stops = parseCsv(read('stops.txt')); return { mode, feedVersion: feed?.feed_version ?? null, stops }; } const feeds = await Promise.all(GTFS.map(loadGtfs)); const osmStops = stops.filter((s) => s.tags); const transit = []; const feedDate = (v) => (v && /^\d{8}/.test(v) ? `${v.slice(0, 4)}-${v.slice(4, 6)}-${v.slice(6, 8)}` : null); for (const f of feeds) { for (const s of f.stops) { const lat = Number(s.stop_lat), lon = Number(s.stop_lon); if (!(lat >= BBOX[0] && lat <= BBOX[2] && lon >= BBOX[1] && lon <= BBOX[3])) continue; let near = null, nd = 40; for (const o of osmStops) { const d = Math.hypot((o.lat - lat) * 111320, (o.lon - lon) * 71500); if (d < nd) { nd = d; near = o; } } const wb = s.wheelchair_boarding === '' || s.wheelchair_boarding == null ? null : Number(s.wheelchair_boarding); const tags = {}; if (near?.tags.wheelchair) tags.wheelchair = near.tags.wheelchair; transit.push({ id: `z${f.mode[0]}${s.stop_id}`, lat: round(lat), lon: round(lon), name: s.stop_name, cat: 'transit', kind: f.mode === 'tram' ? 'tram_stop' : 'bus_stop', ts: near?.ts ?? null, tags, ext: { gtfsWheelchair: wb, feedDate: feedDate(f.feedVersion), mode: f.mode, osmMatch: near?.id ?? null }, }); } } // ---- pedestrian network ---- const nodeCoord = new Map(); for (const el of netRaw.elements) if (el.type === 'node') nodeCoord.set(el.id, [el.lat, el.lon]); const kerbById = new Map(); for (const el of kerbRaw.elements) { const k = el.tags?.kerb; if (k) kerbById.set(el.id, k); } const nodes = []; const nodeIdx = new Map(); const idxOf = (id) => { let i = nodeIdx.get(id); if (i === undefined) { const c = nodeCoord.get(id); i = nodes.push([round(c[0]), round(c[1])]) - 1; nodeIdx.set(id, i); } return i; }; const ways = []; const kerbs = {}; const isNo = (v) => v === 'no' || v === 'private'; for (const el of netRaw.elements) { if (el.type !== 'way') continue; const t = el.tags; const hw = t.highway; if (t.indoor || t.level && t.highway === 'corridor') continue; if (isNo(t.access) && !['yes', 'designated', 'permissive'].includes(t.foot)) continue; if (t.foot === 'no') continue; if (hw === 'cycleway' && !['yes', 'designated', 'permissive'].includes(t.foot)) continue; if (hw === 'service' && (t.access === 'private' || t.service === 'parking_aisle' || t.service === 'drive-through')) continue; if (hw === 'track' && t.foot !== 'yes') continue; if (el.nodes.some((n) => !nodeCoord.has(n))) continue; const w = { id: el.id, hw, ts: day(el.timestamp), nodes: el.nodes.map(idxOf), }; if (t.name) w.name = t.name; const surface = t.surface ?? t['footway:surface'] ?? t['path:surface']; if (surface) w.surface = surface; if (t.smoothness) w.smooth = t.smoothness; if (t.incline) w.incline = t.incline; if (t.width) w.width = t.width; if (t.step_count) w.stepCount = Number(t.step_count) || undefined; if (t.ramp) w.ramp = t.ramp; if (t['ramp:wheelchair']) w.rampWheelchair = t['ramp:wheelchair']; if (t['ramp:stroller']) w.rampStroller = t['ramp:stroller']; if (t.handrail) w.handrail = t.handrail; if (t.footway === 'crossing' || t.footway === 'sidewalk') w.footway = t.footway; if (t.tunnel) w.tunnel = true; if (t.bridge) w.bridge = true; if (t.wheelchair) w.wheelchair = t.wheelchair; if (t['check_date:surface'] || t.check_date) w.checked = t['check_date:surface'] ?? t.check_date; ways.push(w); if (w.footway === 'crossing' || hw === 'footway') { for (const n of [el.nodes[0], el.nodes[el.nodes.length - 1]]) { const k = kerbById.get(n); if (k) kerbs[idxOf(n)] = k; } } } const out = { meta: { fetchedAt: new Date().toISOString(), bbox: BBOX, source: 'OpenStreetMap contributors', license: 'ODbL 1.0', endpoint: OVERPASS, cityData: { name: 'ZTP Kraków GTFS (stops.txt: wheelchair_boarding)', url: 'https://gtfs.ztp.krakow.pl/', feedDates: feeds.map((f) => ({ mode: f.mode, version: f.feedVersion })), license: 'verify at otwartedane.um.krakow.pl before reuse' }, counts: { places: places.length + transit.length, transit: transit.length, toilets: toilets.length, elevators: elevators.length, benches: benches.length, ways: ways.length, nodes: nodes.length }, }, places: [...places, ...transit], toilets, elevators, benches, nodes, ways, kerbs, }; await mkdir('public/data', { recursive: true }); await writeFile('public/data/kk-snapshot.json', JSON.stringify(out)); console.log(out.meta.counts);