// 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'; // S,W,N,E: Wawel + Old Town + Kazimierz out east to Tauron Arena / Czyżyny const BBOX = [50.044, 19.920, 50.074, 20.000]; const bb = BBOX.join(','); const OVERPASS = 'https://overpass-api.de/api/interpreter'; const queries = { places: `[out:json][timeout:240]; ( 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|events_venue|conference_centre|exhibition_centre|marketplace)$"](${bb}); nwr["name"]["leisure"~"^(stadium|sports_centre|swimming_pool|fitness_centre)$"](${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:240]; ( 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:300]; way["highway"~"^(footway|pedestrian|path|steps|living_street|residential|service|tertiary|unclassified|track|cycleway)$"](${bb}); out meta; >; out skel qt;`, // named green areas (parks, gardens, cemeteries...) as outlines: unnamed paths inside them get the area's name in route text parks: `[out:json][timeout:240]; ( way["name"]["leisure"~"^(park|garden|playground)$"](${bb}); relation["name"]["leisure"~"^(park|garden)$"](${bb}); way["name"]["landuse"~"^(recreation_ground|cemetery)$"](${bb}); way["name"]["natural"="wood"](${bb}); ); out geom;`, kerbs: `[out:json][timeout:240]; ( node["kerb"](${bb}); node["highway"="crossing"](${bb}); ); out tags;`, }; // Public Overpass instances rate-limit hard (HTTP 429/504): fetch one query at a time, back off, and rotate mirrors. const MIRRORS = [OVERPASS, 'https://overpass.kumi.systems/api/interpreter', 'https://overpass.private.coffee/api/interpreter']; async function fetchRaw(name) { const file = `scripts/.cache/${name}-${BBOX.join('_')}.json`; // keyed by bbox so a changed area never reuses stale data if (existsSync(file)) return JSON.parse(await readFile(file, 'utf8')); for (let attempt = 1; attempt <= 8; attempt++) { const endpoint = MIRRORS[(attempt - 1) % MIRRORS.length]; try { const res = await fetch(endpoint, { signal: AbortSignal.timeout(280_000), // a hung mirror must not stall the build forever 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(); JSON.parse(text); // a mirror can answer 200 with an HTML error page await writeFile(file, text); return JSON.parse(text); } console.warn(`${name}: HTTP ${res.status} from ${endpoint}, retry ${attempt}`); } catch (e) { console.warn(`${name}: ${e.message} from ${endpoint}, retry ${attempt}`); } await new Promise((r) => setTimeout(r, 15000 * 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 raw = {}; for (const q of Object.keys(queries)) raw[q] = await fetchRaw(q); const { places: placesRaw, support: supportRaw, net: netRaw, kerbs: kerbRaw, parks: parksRaw } = raw; // ---- green areas: closed rings, relations stitched from their outer member ways ---- const r5 = (n) => Math.round(n * 1e5) / 1e5; const pt = (g) => [r5(g.lat), r5(g.lon)]; const same = (a, b) => a[0] === b[0] && a[1] === b[1]; function stitch(segs) { const rings = []; const left = segs.map((s) => s.slice()); while (left.length) { let ring = left.pop(); for (let progress = true; progress && !same(ring[0], ring[ring.length - 1]);) { progress = false; for (let i = 0; i < left.length; i++) { const s = left[i], end = ring[ring.length - 1]; if (same(s[0], end)) ring = ring.concat(s.slice(1)); else if (same(s[s.length - 1], end)) ring = ring.concat(s.slice(0, -1).reverse()); else continue; left.splice(i, 1); progress = true; break; } } if (ring.length >= 4 && same(ring[0], ring[ring.length - 1])) rings.push(ring); } return rings; } const parks = []; for (const el of parksRaw.elements) { const n = el.tags?.name; if (!n) continue; if (el.type === 'way' && el.geometry) { const ring = el.geometry.map(pt); if (ring.length >= 4 && same(ring[0], ring[ring.length - 1])) parks.push({ n, p: ring }); } else if (el.type === 'relation') { const outer = (el.members ?? []).filter((m) => m.type === 'way' && m.role !== 'inner' && m.geometry).map((m) => m.geometry.map(pt)); for (const ring of stitch(outer)) parks.push({ n, p: ring }); } } 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, parks, }; await mkdir('public/data', { recursive: true }); await writeFile('public/data/kk-snapshot.json', JSON.stringify(out)); console.log(out.meta.counts);