Skip to main content

@happyvertical/geo

Geocoding, reverse geocoding, and static map generation with a unified adapter interface. Supports Google Maps and OpenStreetMap (Nominatim) for geocoding, and Mapbox and Google Maps for static map images. Results are cached in memory automatically.

Installation​

pnpm add @happyvertical/geo

Peer dependencies: @happyvertical/cache, @happyvertical/utils.

Usage​

Geocoding​

import { getGeoAdapter } from '@happyvertical/geo';

const adapter = await getGeoAdapter({
provider: 'google',
apiKey: process.env.GOOGLE_MAPS_API_KEY!,
});

const results = await adapter.lookup('Eiffel Tower, Paris');
console.log(results[0].name); // Formatted address
console.log(results[0].latitude); // 48.8583701
console.log(results[0].countryCode); // FR

const locations = await adapter.reverseGeocode(48.8584, 2.2945);

OpenStreetMap (no API key required)​

const adapter = await getGeoAdapter({
provider: 'openstreetmap',
rateLimitDelay: 1000, // ms between requests (default)
});

const results = await adapter.lookup('Big Ben, London');

Both providers implement findPoisNear(lat, lon, radiusMeters, options?) for discovering businesses, landmarks, and amenities around a coordinate. It's an optional method on the adapter — feature-detect before calling:

if (typeof adapter.findPoisNear === 'function') {
const cafes = await adapter.findPoisNear(48.8566, 2.3522, 300, {
types: ['cafe'],
limit: 10,
});
for (const cafe of cafes) {
console.log(cafe.name, '@', cafe.latitude, cafe.longitude);
}
}

Google routes the request through the Places API (Nearby Search) so the API key needs the Places API enabled in Google Cloud in addition to Geocoding. The first entry of options.types becomes the request's type filter; additional entries fan out across separate requests and the results are deduped by place_id. Max radius 50 000 m per Places API.

OpenStreetMap uses the public Overpass API. No key, but the same community use-policy as Nominatim applies — cache aggressively and reuse a rateLimitDelay that matches your traffic. When types is omitted, the query looks across amenity, shop, tourism, leisure, office, historic, and craft tag keys. When supplied, values are matched against each of those keys so you can pass ['cafe'] or ['supermarket'] without knowing the exact tag.

Environment Variable Configuration​

Set HAVE_GEO_PROVIDER, HAVE_GEO_TIMEOUT, HAVE_GEO_MAX_RESULTS, HAVE_GEO_RATE_LIMIT_DELAY, HAVE_GEO_USER_AGENT, and GOOGLE_MAPS_API_KEY to configure without passing options:

const adapter = await getGeoAdapter(); // reads from env

User-provided options always take precedence over environment variables.

Static Maps​

Generate static map URLs or fetch map images for embedding:

import { getStaticMapUrl, fetchStaticMap, getOGMapUrl } from '@happyvertical/geo';

// Mapbox URL (default provider)
const url = getStaticMapUrl(53.5461, -113.4938, {
provider: 'mapbox',
zoom: 14,
width: 1200,
height: 630,
});

// Google Maps URL
const googleUrl = getStaticMapUrl(53.5461, -113.4938, {
provider: 'google',
googleMapType: 'roadmap',
});

// Fetch image as Buffer
const result = await fetchStaticMap(53.5461, -113.4938, { provider: 'mapbox' });
await fs.writeFile('map.png', result.buffer);

// OG-sized map (1200x630) convenience function
const ogUrl = getOGMapUrl(53.5461, -113.4938);

API​

getGeoAdapter(options?): Promise<GeoAdapter>​

Factory function returning a geocoding adapter. Options are a discriminated union on provider:

OptionGoogleOSMDescription
provider'google''openstreetmap'Required (or set HAVE_GEO_PROVIDER)
apiKeyrequired—Google Maps API key
timeoutoptionaloptionalRequest timeout ms (default: 10000)
maxResultsoptionaloptionalMax results (default: 10)
userAgent—optionalCustom User-Agent for Nominatim
rateLimitDelay—optionalDelay between requests ms (default: 1000)
timezoneLookup'offline' | 'api' | 'none''offline' | 'none'How Location.timezone is filled (default 'offline'; env HAVE_GEO_TIMEZONE_LOOKUP)

Time zones​

Every result carries an IANA timezone (for example 'America/Edmonton') derived from its coordinates. The default 'offline' mode uses the bundled @photostructure/tz-lookup table (CC0-1.0, ~90 KB, no network requests); it can pick a neighbouring zone within a few kilometres of a zone border. With the Google provider, timezoneLookup: 'api' asks the Google Time Zone API instead (enable it for the key; billed per request; one call per distinct coordinate; returns CLDR canonical IDs such as Asia/Calcutta; distinct coordinates are looked up concurrently) and falls back to the offline table if the call fails, logging one warning per adapter. 'none' leaves timezone unset. Values are case-insensitive; any other value makes getGeoAdapter throw a GeoError (INVALID_OPTION). OpenStreetMap has no time zone API, so 'api' there logs one warning and uses 'offline'. The offline lookup is also exported as timezoneForCoordinates(latitude, longitude).

GeoAdapter Interface​

interface GeoAdapter {
lookup(query: string): Promise<Location[]>;
reverseGeocode(latitude: number, longitude: number): Promise<Location[]>;
}

Location​

interface Location {
id: string;
type: 'country' | 'region' | 'city' | 'address' | 'point_of_interest' | 'unknown';
name: string;
latitude: number;
longitude: number;
addressComponents: {
streetNumber?: string; streetName?: string; city?: string;
region?: string; country?: string; postalCode?: string;
};
countryCode: string;
timezone?: string;
raw: any;
}

Static Map Functions​

  • getStaticMapUrl(lat, lng, options?): string — Generate a Mapbox or Google static map URL.
  • fetchStaticMap(lat, lng, options?): Promise<StaticMapResult> — Fetch the map image as a Buffer.
  • getOGMapUrl(lat, lng, options?): string — Convenience for 1200x630 Open Graph maps.

StaticMapOptions supports provider ('mapbox' | 'google'), width, height, zoom, markerColor, showMarker, mapboxToken/googleApiKey, mapboxStyle, googleMapType, scale, and markers.

Error Classes​

All extend GeoError:

  • GeoError — Base error with code and provider fields
  • InvalidQueryError — Empty or malformed query
  • RateLimitError — Provider rate limit exceeded
  • AuthenticationError — Invalid API key
  • NoResultsError — No results for query

Utility Functions​

  • timezoneForCoordinates(lat, lng) — Offline IANA time zone for a coordinate (undefined if invalid)
  • validateCoordinates(lat, lng) — Returns { valid, error? }
  • isValidLatitude(lat) / isValidLongitude(lng) — Bounds check
  • normalizeCountryCode(code) — Normalize to ISO 3166-1 alpha-2
  • mapGooglePlaceType(types) / mapOSMPlaceType(type, addressType?) — Map provider types to Location['type']

License​

ISC