239 lines
9.4 KiB
JavaScript
239 lines
9.4 KiB
JavaScript
|
|
// 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);
|