167 lines
12 KiB
JavaScript
167 lines
12 KiB
JavaScript
/**
|
|
* osm.ts — OpenStreetMap Nominatim integration for neighborhood-level lookups.
|
|
*
|
|
* Complements GADM (which stops at municipality level) with OSM's
|
|
* suburb/quarter/neighbourhood data.
|
|
*
|
|
* Uses Nominatim API — free, no key, but:
|
|
* - Max 1 req/sec (we add a small delay)
|
|
* - Must set a proper User-Agent
|
|
* - Results cached to disk to avoid repeat calls
|
|
*/
|
|
import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'fs';
|
|
import { join, dirname } from 'path';
|
|
import { createHash } from 'crypto';
|
|
// ────────── cache helpers ──────────
|
|
function cacheKey(prefix, input) {
|
|
return `${prefix}_${createHash('md5').update(input).digest('hex')}`;
|
|
}
|
|
function readCache(dir, key) {
|
|
const fp = join(dir, 'osm', `${key}.json`);
|
|
if (!existsSync(fp))
|
|
return null;
|
|
try {
|
|
return JSON.parse(readFileSync(fp, 'utf-8'));
|
|
}
|
|
catch {
|
|
return null;
|
|
}
|
|
}
|
|
function writeCache(dir, key, data) {
|
|
try {
|
|
const fp = join(dir, 'osm', `${key}.json`);
|
|
mkdirSync(dirname(fp), { recursive: true });
|
|
writeFileSync(fp, JSON.stringify(data, null, 2));
|
|
}
|
|
catch (e) {
|
|
console.error('[OSM] cache write failed:', e);
|
|
}
|
|
}
|
|
// ────────── API ──────────
|
|
const NOMINATIM_BASE = 'https://nominatim.openstreetmap.org';
|
|
const USER_AGENT = 'PolymechGADM/1.0 (dev-trial)';
|
|
/** Rate limit: track last request time */
|
|
let lastRequestMs = 0;
|
|
async function nominatimFetch(path, params) {
|
|
// Respect 1 req/sec rate limit
|
|
const now = Date.now();
|
|
const elapsed = now - lastRequestMs;
|
|
if (elapsed < 1100) {
|
|
await new Promise(r => setTimeout(r, 1100 - elapsed));
|
|
}
|
|
lastRequestMs = Date.now();
|
|
const url = new URL(path, NOMINATIM_BASE);
|
|
url.searchParams.set('format', 'json');
|
|
for (const [k, v] of Object.entries(params)) {
|
|
url.searchParams.set(k, v);
|
|
}
|
|
const res = await fetch(url.toString(), {
|
|
headers: { 'User-Agent': USER_AGENT },
|
|
});
|
|
if (!res.ok) {
|
|
throw new Error(`Nominatim ${res.status}: ${res.statusText}`);
|
|
}
|
|
return res.json();
|
|
}
|
|
/**
|
|
* Search OSM Nominatim for a place.
|
|
*
|
|
* ```ts
|
|
* const results = await osmSearch({ query: 'Johannstadt, Dresden', countryCode: 'de' });
|
|
* ```
|
|
*/
|
|
export async function osmSearch(opts) {
|
|
const key = cacheKey('search', `${opts.query}_${opts.countryCode || ''}_${opts.limit || 10}`);
|
|
if (opts.cacheDir) {
|
|
const cached = readCache(opts.cacheDir, key);
|
|
if (cached)
|
|
return cached;
|
|
}
|
|
const params = {
|
|
q: opts.query,
|
|
limit: String(opts.limit || 10),
|
|
addressdetails: '1',
|
|
};
|
|
if (opts.countryCode) {
|
|
params.countrycodes = opts.countryCode;
|
|
}
|
|
const raw = await nominatimFetch('/search', params);
|
|
const results = raw.map((r) => ({
|
|
placeId: r.place_id,
|
|
displayName: r.display_name,
|
|
name: r.name || r.display_name.split(',')[0],
|
|
type: r.type,
|
|
class: r.class,
|
|
lat: parseFloat(r.lat),
|
|
lon: parseFloat(r.lon),
|
|
address: r.address,
|
|
}));
|
|
if (opts.cacheDir) {
|
|
writeCache(opts.cacheDir, key, results);
|
|
}
|
|
return results;
|
|
}
|
|
/**
|
|
* Get neighborhoods/suburbs within a city using Overpass API.
|
|
* Returns place nodes tagged as suburb, quarter, or neighbourhood.
|
|
*
|
|
* ```ts
|
|
* const hoods = await osmNeighborhoods({ city: 'Dresden', country: 'Germany' });
|
|
* ```
|
|
*/
|
|
export async function osmNeighborhoods(opts) {
|
|
const key = cacheKey('neighborhoods', `${opts.city}_${opts.country || ''}`);
|
|
if (opts.cacheDir) {
|
|
const cached = readCache(opts.cacheDir, key);
|
|
if (cached)
|
|
return cached;
|
|
}
|
|
// Use Overpass API to find all suburbs/quarters/neighbourhoods in the city
|
|
const cityFilter = opts.country
|
|
? `"${opts.city}", "${opts.country}"`
|
|
: `"${opts.city}"`;
|
|
const overpassQuery = `
|
|
[out:json][timeout:30];
|
|
area[name=${cityFilter}]->.searchArea;
|
|
(
|
|
node["place"="suburb"](area.searchArea);
|
|
node["place"="quarter"](area.searchArea);
|
|
node["place"="neighbourhood"](area.searchArea);
|
|
node["place"="city_district"](area.searchArea);
|
|
);
|
|
out body;
|
|
`.trim();
|
|
const res = await fetch('https://overpass-api.de/api/interpreter', {
|
|
method: 'POST',
|
|
headers: {
|
|
'Content-Type': 'application/x-www-form-urlencoded',
|
|
'User-Agent': USER_AGENT,
|
|
},
|
|
body: `data=${encodeURIComponent(overpassQuery)}`,
|
|
});
|
|
if (!res.ok) {
|
|
throw new Error(`Overpass API ${res.status}: ${res.statusText}`);
|
|
}
|
|
const data = await res.json();
|
|
const results = (data.elements || []).map((el) => ({
|
|
placeId: el.id,
|
|
displayName: `${el.tags?.name || 'Unknown'}, ${opts.city}`,
|
|
name: el.tags?.name || 'Unknown',
|
|
type: el.tags?.place || 'unknown',
|
|
class: 'place',
|
|
lat: el.lat,
|
|
lon: el.lon,
|
|
address: {
|
|
suburb: el.tags?.place === 'suburb' ? el.tags?.name : undefined,
|
|
city: opts.city,
|
|
country: opts.country,
|
|
},
|
|
}));
|
|
// Sort alphabetically
|
|
results.sort((a, b) => a.name.localeCompare(b.name));
|
|
if (opts.cacheDir) {
|
|
writeCache(opts.cacheDir, key, results);
|
|
}
|
|
return results;
|
|
}
|
|
//# sourceMappingURL=data:application/json;base64,eyJ2ZXJzaW9uIjozLCJmaWxlIjoib3NtLmpzIiwic291cmNlUm9vdCI6IiIsInNvdXJjZXMiOlsiLi4vc3JjL29zbS50cyJdLCJuYW1lcyI6W10sIm1hcHBpbmdzIjoiQUFBQTs7Ozs7Ozs7OztHQVVHO0FBRUgsT0FBTyxFQUFFLFVBQVUsRUFBRSxZQUFZLEVBQUUsYUFBYSxFQUFFLFNBQVMsRUFBRSxNQUFNLElBQUksQ0FBQztBQUN4RSxPQUFPLEVBQUUsSUFBSSxFQUFFLE9BQU8sRUFBRSxNQUFNLE1BQU0sQ0FBQztBQUNyQyxPQUFPLEVBQUUsVUFBVSxFQUFFLE1BQU0sUUFBUSxDQUFDO0FBb0RwQyxzQ0FBc0M7QUFFdEMsU0FBUyxRQUFRLENBQUMsTUFBYyxFQUFFLEtBQWE7SUFDM0MsT0FBTyxHQUFHLE1BQU0sSUFBSSxVQUFVLENBQUMsS0FBSyxDQUFDLENBQUMsTUFBTSxDQUFDLEtBQUssQ0FBQyxDQUFDLE1BQU0sQ0FBQyxLQUFLLENBQUMsRUFBRSxDQUFDO0FBQ3hFLENBQUM7QUFFRCxTQUFTLFNBQVMsQ0FBSSxHQUFXLEVBQUUsR0FBVztJQUMxQyxNQUFNLEVBQUUsR0FBRyxJQUFJLENBQUMsR0FBRyxFQUFFLEtBQUssRUFBRSxHQUFHLEdBQUcsT0FBTyxDQUFDLENBQUM7SUFDM0MsSUFBSSxDQUFDLFVBQVUsQ0FBQyxFQUFFLENBQUM7UUFBRSxPQUFPLElBQUksQ0FBQztJQUNqQyxJQUFJLENBQUM7UUFDRCxPQUFPLElBQUksQ0FBQyxLQUFLLENBQUMsWUFBWSxDQUFDLEVBQUUsRUFBRSxPQUFPLENBQUMsQ0FBTSxDQUFDO0lBQ3RELENBQUM7SUFBQyxNQUFNLENBQUM7UUFDTCxPQUFPLElBQUksQ0FBQztJQUNoQixDQUFDO0FBQ0wsQ0FBQztBQUVELFNBQVMsVUFBVSxDQUFDLEdBQVcsRUFBRSxHQUFXLEVBQUUsSUFBYTtJQUN2RCxJQUFJLENBQUM7UUFDRCxNQUFNLEVBQUUsR0FBRyxJQUFJLENBQUMsR0FBRyxFQUFFLEtBQUssRUFBRSxHQUFHLEdBQUcsT0FBTyxDQUFDLENBQUM7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
|