GdzieWjade/scripts/build-data.mjs

239 lines
9.4 KiB
JavaScript
Raw Normal View History

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';
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);