2026-10-03 13:33:50 +02:00
// 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' ;
2026-10-03 18:22:27 +02:00
// 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 ] ;
2026-10-03 13:33:50 +02:00
const bb = BBOX . join ( ',' ) ;
const OVERPASS = 'https://overpass-api.de/api/interpreter' ;
const queries = {
2026-10-03 18:22:27 +02:00
places : ` [out:json][timeout:240];
2026-10-03 13:33:50 +02:00
(
2026-10-03 18:22:27 +02:00
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 } ) ;
2026-10-03 13:33:50 +02:00
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 ; ` ,
2026-10-03 18:22:27 +02:00
support : ` [out:json][timeout:240];
2026-10-03 13:33:50 +02:00
(
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 ; ` ,
2026-10-03 18:22:27 +02:00
net : ` [out:json][timeout:300];
2026-10-03 13:33:50 +02:00
way [ "highway" ~ "^(footway|pedestrian|path|steps|living_street|residential|service|tertiary|unclassified|track|cycleway)$" ] ( $ { bb } ) ;
out meta ;
> ;
out skel qt ; ` ,
2026-10-03 19:14:41 +02:00
// 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 ; ` ,
2026-10-03 18:22:27 +02:00
kerbs : ` [out:json][timeout:240];
2026-10-03 13:33:50 +02:00
(
node [ "kerb" ] ( $ { bb } ) ;
node [ "highway" = "crossing" ] ( $ { bb } ) ;
) ;
out tags ; ` ,
} ;
2026-10-03 18:22:27 +02:00
// 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' ] ;
2026-10-03 13:33:50 +02:00
async function fetchRaw ( name ) {
2026-10-03 18:22:27 +02:00
const file = ` scripts/.cache/ ${ name } - ${ BBOX . join ( '_' ) } .json ` ; // keyed by bbox so a changed area never reuses stale data
2026-10-03 13:33:50 +02:00
if ( existsSync ( file ) ) return JSON . parse ( await readFile ( file , 'utf8' ) ) ;
2026-10-03 18:22:27 +02:00
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 } ` ) ;
2026-10-03 13:33:50 +02:00
}
2026-10-03 18:22:27 +02:00
await new Promise ( ( r ) => setTimeout ( r , 15000 * attempt ) ) ;
2026-10-03 13:33:50 +02:00
}
throw new Error ( ` Overpass failed for ${ name } ` ) ;
}
const round = ( n ) => Math . round ( n * 1e6 ) / 1e6 ;
const day = ( ts ) => ( ts ? ts . slice ( 0 , 10 ) : null ) ;
2026-10-03 18:22:27 +02:00
const raw = { } ;
for ( const q of Object . keys ( queries ) ) raw [ q ] = await fetchRaw ( q ) ;
2026-10-03 19:14:41 +02:00
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 } ) ;
}
}
2026-10-03 13:33:50 +02:00
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 } ,
} ,
2026-10-03 19:14:41 +02:00
places : [ ... places , ... transit ] , toilets , elevators , benches , nodes , ways , kerbs , parks ,
2026-10-03 13:33:50 +02:00
} ;
await mkdir ( 'public/data' , { recursive : true } ) ;
await writeFile ( 'public/data/kk-snapshot.json' , JSON . stringify ( out ) ) ;
console . log ( out . meta . counts ) ;