@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');
POI (Point-of-Interest) search​
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:
| Option | OSM | Description | |
|---|---|---|---|
provider | 'google' | 'openstreetmap' | Required (or set HAVE_GEO_PROVIDER) |
apiKey | required | — | Google Maps API key |
timeout | optional | optional | Request timeout ms (default: 10000) |
maxResults | optional | optional | Max results (default: 10) |
userAgent | — | optional | Custom User-Agent for Nominatim |
rateLimitDelay | — | optional | Delay 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 withcodeandproviderfieldsInvalidQueryError— Empty or malformed queryRateLimitError— Provider rate limit exceededAuthenticationError— Invalid API keyNoResultsError— No results for query
Utility Functions​
timezoneForCoordinates(lat, lng)— Offline IANA time zone for a coordinate (undefinedif invalid)validateCoordinates(lat, lng)— Returns{ valid, error? }isValidLatitude(lat)/isValidLongitude(lng)— Bounds checknormalizeCountryCode(code)— Normalize to ISO 3166-1 alpha-2mapGooglePlaceType(types)/mapOSMPlaceType(type, addressType?)— Map provider types toLocation['type']
License​
ISC