← back to NationalPaperHangers
lib/geocode.js
114 lines
// Address → {lat, lng, county, town, zip, state, display_name} via OSM
// Nominatim. Free, no API key, but rate-limited to ~1 req/sec by their
// usage policy. We add a 10s in-memory cache keyed on the normalised
// address string to avoid hammering them on retries/refreshes.
//
// User-Agent is mandatory per Nominatim policy — generic UAs get blocked.
//
// Returned fields:
// lat, lng numbers
// county "Los Angeles County" (drop the trailing word when
// matching against service_counties)
// town city / town / village / hamlet / municipality
// zip postal_code or null
// state short state code (2-letter)
// display_name human-readable address echo
//
// On failure → null. Callers should branch on null and surface a friendly
// "we couldn't find that address" rather than 500.
const https = require('https');
const UA = 'NationalPaperHangers/1.0 (info@nationalpaperhangers.com)';
const _cache = new Map();
const TTL_MS = 10 * 60 * 1000;
function _normalise(q) {
return String(q || '').trim().toLowerCase().replace(/\s+/g, ' ');
}
function geocode(query) {
return new Promise((resolve) => {
const norm = _normalise(query);
if (!norm) return resolve(null);
const cached = _cache.get(norm);
if (cached && Date.now() - cached.at < TTL_MS) return resolve(cached.value);
const params = new URLSearchParams({
q: query,
format: 'jsonv2',
addressdetails: '1',
countrycodes: 'us', // wallcovering market is US-only for now
limit: '1'
});
const opts = {
hostname: 'nominatim.openstreetmap.org',
path: '/search?' + params.toString(),
method: 'GET',
headers: { 'User-Agent': UA, 'Accept': 'application/json' }
};
const req = https.request(opts, (res) => {
let buf = '';
res.on('data', c => buf += c);
res.on('end', () => {
try {
const arr = JSON.parse(buf);
if (!Array.isArray(arr) || arr.length === 0) {
_cache.set(norm, { at: Date.now(), value: null });
return resolve(null);
}
const r = arr[0];
const a = r.address || {};
const stateLong = a.state || '';
// ISO_3166_2 is sometimes set ("US-CA"); fall back to the long-name map.
const stateMap = {
'alabama':'AL','alaska':'AK','arizona':'AZ','arkansas':'AR','california':'CA','colorado':'CO',
'connecticut':'CT','delaware':'DE','district of columbia':'DC','florida':'FL','georgia':'GA',
'hawaii':'HI','idaho':'ID','illinois':'IL','indiana':'IN','iowa':'IA','kansas':'KS',
'kentucky':'KY','louisiana':'LA','maine':'ME','maryland':'MD','massachusetts':'MA','michigan':'MI',
'minnesota':'MN','mississippi':'MS','missouri':'MO','montana':'MT','nebraska':'NE','nevada':'NV',
'new hampshire':'NH','new jersey':'NJ','new mexico':'NM','new york':'NY','north carolina':'NC',
'north dakota':'ND','ohio':'OH','oklahoma':'OK','oregon':'OR','pennsylvania':'PA','rhode island':'RI',
'south carolina':'SC','south dakota':'SD','tennessee':'TN','texas':'TX','utah':'UT','vermont':'VT',
'virginia':'VA','washington':'WA','west virginia':'WV','wisconsin':'WI','wyoming':'WY'
};
const state = (a['ISO3166-2-lvl4'] || '').replace(/^US-/, '')
|| stateMap[stateLong.toLowerCase()]
|| stateLong;
const value = {
lat: parseFloat(r.lat),
lng: parseFloat(r.lon),
county: a.county || null, // includes "County" suffix; caller may strip
town: a.city || a.town || a.village || a.hamlet || a.municipality || a.suburb || null,
zip: a.postcode || null,
state,
display_name: r.display_name
};
_cache.set(norm, { at: Date.now(), value });
resolve(value);
} catch {
_cache.set(norm, { at: Date.now(), value: null });
resolve(null);
}
});
});
req.on('error', () => resolve(null));
req.setTimeout(8000, () => { req.destroy(); resolve(null); });
req.end();
});
}
// Haversine distance between two lat/lng pairs, in miles.
function distanceMiles(lat1, lng1, lat2, lng2) {
const toRad = d => (d * Math.PI) / 180;
const R = 3958.8; // earth radius in miles
const dLat = toRad(lat2 - lat1);
const dLng = toRad(lng2 - lng1);
const a = Math.sin(dLat/2) ** 2 +
Math.cos(toRad(lat1)) * Math.cos(toRad(lat2)) * Math.sin(dLng/2) ** 2;
return R * 2 * Math.asin(Math.min(1, Math.sqrt(a)));
}
module.exports = { geocode, distanceMiles };