Skip to main content

@happyvertical/smrt-places

Hierarchical place management with geocoding integration and spatial queries. Supports both real-world locations (with coordinates) and abstract places (virtual worlds, game zones).

Installation​

pnpm add @happyvertical/smrt-places

Usage​

Lookup or create places with organic database growth​

import { PlaceCollection, PlaceTypeCollection } from '@happyvertical/smrt-places';

const places = await PlaceCollection.create();
const types = await PlaceTypeCollection.create();

// DB-first lookup, geocodes and creates if not found
const place = await places.lookupOrCreate('1600 Amphitheatre Parkway, Mountain View, CA');

if (place) {
console.log(`${place.name}: ${place.latitude}, ${place.longitude}`);
console.log(`Source: ${place.source}`); // 'openstreetmap' or 'google'
}

// Build hierarchy: Country > State > City
const country = await places.create({
typeId: (await types.getOrCreate('country')).id,
name: 'United States',
countryCode: 'US',
});

const city = await places.create({
typeId: (await types.getOrCreate('city')).id,
parentId: country.id,
name: 'San Francisco',
latitude: 37.7749,
longitude: -122.4194,
});

// Hierarchy traversal
const hierarchy = await city.getHierarchy();
console.log(hierarchy.ancestors.map(p => p.name)); // ['United States']

// Proximity search (Haversine distance, sorted nearest-first)
const nearby = await places.searchByProximity(37.7749, -122.4194, 10); // 10km radius

Discover POIs around a coordinate​

discoverNearby composes @happyvertical/geo's findPoisNear with the collection's idempotent-persist logic. Each POI returned by the provider is either reused from the local DB (matched by provider externalId) or created as a fresh Place row. Repeat calls for the same area are effectively free — the provider's own id becomes Place.externalId, so a second scan doesn't duplicate anything.

const pois = await places.discoverNearby(53.5461, -113.4938, 250, {
geoProvider: 'openstreetmap', // default
types: ['cafe'], // optional filter — passed through to the provider
limit: 10,
});

for (const poi of pois) {
console.log(poi.name, poi.externalId);
}

Requires a geo provider that implements findPoisNear. Throws a clear error otherwise so callers can fall back to lookupOrCreate (address-level) or switch providers.

Resolve POIs along a GPS track​

For workflows like "what places does this drive/hike pass through?", resolveTrackPlaces walks an ordered array of points, collapses close samples into grid cells, throttles the provider between requests, and returns the deduplicated Place rows.

const track = [
{ lat: 53.5461, lng: -113.4938 },
{ lat: 53.5465, lng: -113.4942 },
{ lat: 53.5470, lng: -113.4950 },
// ...hundreds more along a route
];

const result = await places.resolveTrackPlaces(track, {
radiusMeters: 50, // per-point POI search radius
bucketMeters: 50, // collapse points within this distance into one request
throttleMs: 1100, // ≥1s is safe for public Overpass/Nominatim
types: ['cafe', 'restaurant'],
});

console.log(`Hit ${result.requestCount} buckets, ${result.cacheHitCount} cached, ${result.places.length} distinct places`);

Bucketing is the main cost-saver: a 20-minute drive at 1 Hz GPS produces 1200 raw samples but often only 100–200 distinct 50 m cells, so the provider only gets called 100–200 times. throttleMs keeps you within Overpass/Nominatim's 1 req/sec community limit out of the box; drop it to 100 ms or so on paid tiers.

Owned assets​

import { AssetCollection } from '@happyvertical/smrt-assets';

const assets = await AssetCollection.create();
const floorplan = await assets.create({
name: 'main-hall-floorplan.png',
sourceUri: 'file:///tmp/main-hall-floorplan.png',
mimeType: 'image/png',
});

await place.addAsset(floorplan, 'floorplan');
await places.addAsset(place.id!, floorplan, 'gallery', 1);

const floorplans = await place.getAssets('floorplan');
const galleryAssets = await places.getAssets(place.id!, 'gallery');

API​

Models​

ExportDescription
PlaceHierarchical place with optional geo fields, STI enabled
PlaceTypeSlug-based classification (country, city, building, zone, room)
PlaceAssetDedicated owned-asset join stored in place_assets with relationship and sortOrder

Collections​

ExportDescription
PlaceCollectionCRUD + lookupOrCreate(), searchByProximity(), findByCoordinates(), getRootPlaces(), getByType(), findWithGlobals()
PlaceAssetCollectionDirect access to place_assets rows plus asset helper wrappers
PlaceTypeCollectionCRUD + getOrCreate() for idempotent type creation

Types​

ExportDescription
GeoDataGeographic data structure
LookupOrCreateOptionsOptions for lookupOrCreate()
PlaceHierarchyHierarchy traversal result
PlaceOptionsPlace creation options
PlaceTypeOptionsPlaceType creation options

Utilities​

ExportDescription
calculateDistanceHaversine distance between two coordinates (km)
areCoordinatesNearCheck if two coordinates are within threshold
validateCoordinatesValidate lat/lng values
parseCoordinatesParse coordinate string to lat/lng
formatCoordinatesFormat lat/lng as string
generateDisplayNameBuild display name from address components
locationToGeoDataConvert location response to GeoData
mapLocationTypeToPlaceTypeMap geocoder location type to PlaceType slug
normalizeAddressComponentsNormalize address components from geocoder

Instance Methods (Place)​

getParent(), getChildren(), getAncestors(), getDescendants(), getHierarchy() -- hierarchy traversal on any Place instance.

Owned asset helpers are available on both Place and PlaceCollection via getAssets(), addAsset(), and removeAsset(). Common relationships include hero, floorplan, gallery, and attachment.

Dependencies​

PackagePurpose
@happyvertical/smrt-coreSmrtObject/SmrtCollection base classes
@happyvertical/smrt-tenancyOptional tenant scoping
@happyvertical/geoGeocoding providers (OpenStreetMap, Google Maps)
@happyvertical/sqlDatabase operations
@happyvertical/aiAI integration