← 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 };