@rapideditor/location-conflation
Version:
Define complex geographic regions by including and excluding country codes and geojson shapes
881 lines (773 loc) • 33.1 kB
text/typescript
import * as CountryCoder from '@rapideditor/country-coder';
import * as Polyclip from 'polyclip-ts';
import calcArea from '@mapbox/geojson-area';
import circleToPolygon from 'circle-to-polygon';
import precision from 'geojson-precision';
import prettyStringify from 'json-stringify-pretty-compact';
import whichPolygon from 'which-polygon';
import type { Geom } from 'polyclip-ts';
import type {
HasLocationSet,
HasLocationSetID,
LocoFeature,
LocoProperties,
Location,
LocationID,
LocationSet,
LocationSetID,
ResolvedLocation,
ResolvedLocationSet,
StringifyOptions,
ValidatedLocation,
ValidatedLocationSet,
Vec2,
} from './types.ts';
// Re-export everything from types.ts so consumers can `import { … } from '@rapideditor/location-conflation'`
export type * from './types.ts';
/**
* LocationConflation lets you define complex geographic regions by including and
* excluding country codes, points, and custom `.geojson` shapes.
*
* It resolves these definitions into GeoJSON `Feature`s with polygon geometries,
* caching expensive polygon-clipping operations for performance.
*
* @example
* ```ts
* import { LocationConflation } from '@rapideditor/location-conflation';
*
* const loco = new LocationConflation(myFeatureCollection);
*
* const result = loco.resolveLocationSet({ include: ['de'], exclude: ['de-berlin.geojson'] });
* console.log(result.feature); // GeoJSON Feature of Germany minus Berlin
* ```
*/
export class LocationConflation {
/**
* Internal cache of all resolved features, keyed by stable identifiers.
* Identifiers look like:
* - `'[8.67039,49.41882]'` — point locations
* - `'de-hamburg.geojson'` — geojson file locations
* - `'Q2'` — country-coder locations (Wikidata QIDs)
* - `'+[Q2]-[Q18,Q27611]'` — aggregated location sets
*/
private _resolved: Map<LocationID | LocationSetID, LocoFeature>;
// Internal cache of registered LocationSetID → approximate area in km²
private _registered: Map<LocationSetID, number>;
// Spatial index state, built by `registerLocationSets()` and rebuilt by `rebuildIndex()`.
// Inverted index: locationID → Set of locationSetIDs that include/exclude that component location.
// Reads as English: "the sets including this location" / "the sets excluding this location".
private _setsIncluding: Map<LocationID, Set<LocationSetID>>;
private _setsExcluding: Map<LocationID, Set<LocationSetID>>;
// A `WhichPolygon` spatial index for custom .geojson and point-radius features only.
// CountryCoder regions are looked up via CountryCoder's own spatial index.
private _spatialIndex: ReturnType<typeof whichPolygon<{ id: string }>> | null;
/**
* @constructor
* Creates a new LocationConflation instance.
* @param fc - Optional GeoJSON FeatureCollection of known features with filename-like IDs
* (e.g. `"something.geojson"`). Accepts a standard `GeoJSON.FeatureCollection` from
* `@types/geojson` — no casts needed. Features without a `.geojson` id are silently skipped.
*/
constructor(fc?: GeoJSON.FeatureCollection) {
this._resolved = new Map();
this._registered = new Map();
this._setsIncluding = new Map();
this._setsExcluding = new Map();
this._spatialIndex = null;
// Load any .geojson features from the input FeatureCollection.
if (fc) {
this.addFeatures(fc);
}
this._setupWorld(); // will also call rebuildIndex()
}
/**
* Setup the world objects.
* This ensures that we both have our world location and world locationSet available.
*
* Calling `registerLocationSets` is important here because allows `locationSetsAt`
* to return the world locationSet, even if no locationSets have been otherwise registered.
*/
private _setupWorld(): void {
// Note that CountryCoder's "world" is an aggregate of its administrative boundaries.
// (It excludes regions like the north pole or ocean.)
// What we want for our purposes is a polygon that covers the entire world.
const WORLD_GEOMETRY: GeoJSON.Polygon = {
type: 'Polygon',
coordinates: [[[-180, -90], [180, -90], [180, 90], [-180, 90], [-180, -90]]],
};
// 'Q2' - A Location representing the entire world.
const WORLD_LOCATION: LocoFeature = {
type: 'Feature',
id: 'Q2',
properties: { id: 'Q2', area: _getArea(WORLD_GEOMETRY) },
geometry: WORLD_GEOMETRY
};
// '+[Q2]' - A LocationSet that just includes the entire world.
const WORLD_LOCATIONSET: HasLocationSet = {
locationSet: { include: ['Q2'] }
};
this._resolved.set('Q2', WORLD_LOCATION);
this.registerLocationSets([WORLD_LOCATIONSET]); // will also call rebuildIndex()
}
/**
* Returns whether a coordinate pair is within valid WGS84 bounds.
*/
private _isValidPoint(loc: Vec2): boolean {
const [lon, lat] = loc;
return (
Number.isFinite(lon) && lon >= -180 && lon <= 180 &&
Number.isFinite(lat) && lat >= -90 && lat <= 90
);
}
/**
* Adds `.geojson` features to the cache of known locations.
*
* Each feature must have a filename-like `id` ending in `.geojson` (either on
* the feature itself or in its `properties`). Features without a qualifying
* `id` are silently skipped.
*
* The method normalizes each feature into the {@link LocoFeature} shape
* (lowercased id, guaranteed `area` property, polygon geometry). If a
* feature with the same id already exists in the cache, it is replaced.
*
* @param fc - A GeoJSON FeatureCollection containing custom locations.
* Accepts a standard `GeoJSON.FeatureCollection` — no casts needed.
*
* @example
* ```ts
* // Load additional features after construction
* const loco = new LocationConflation();
* loco.addFeatures(additionalFeatureCollection);
*
* // Now these features are available for resolving
* const result = loco.resolveLocation('philly_metro.geojson');
* ```
*/
addFeatures(fc: GeoJSON.FeatureCollection): void {
if (fc?.type !== 'FeatureCollection' || !Array.isArray(fc.features)) return;
for (const feature of fc.features) {
const props = feature.properties ?? {};
// Get `id` from either `id` or `properties.id`.
// Features without a valid id are silently skipped.
let id = feature.id ?? props['id'];
if (!id || !/^\S+\.geojson$/i.test(String(id))) continue;
// Ensure `id` exists and is lowercase
id = String(id).toLowerCase();
const geometry = feature.geometry as GeoJSON.Polygon | GeoJSON.MultiPolygon;
// Ensure `area` property exists.
// The purpose of the `area` property is to enable rough size comparisons.
const existingArea = props['area'] as number | undefined;
const area = existingArea || _getArea(geometry);
const lcFeature: LocoFeature = {
type: 'Feature',
id,
properties: { ...props, id, area } as LocoProperties,
geometry,
};
this._resolved.set(id, lcFeature);
}
this.rebuildIndex(); // Need to rebuild the index after adding new locations.
}
/**
* Removes `.geojson` features from the cache by id.
* Each id should be a filename-like string ending in `.geojson`.
* Ids are matched case-insensitively (lowercased before lookup).
* Non-`.geojson` ids and ids not present in the cache are silently ignored.
*
* @param ids - One or more feature ids to remove.
*
* @example
* ```ts
* loco.removeFeatures('philly_metro.geojson', 'dc_metro.geojson');
* ```
*/
removeFeatures(...ids: string[]): void {
for (const id of ids) {
if (!/^\S+\.geojson$/i.test(id)) continue;
this._resolved.delete(id.toLowerCase());
}
this.rebuildIndex(); // Need to rebuild the index after removing locations.
}
/**
* Removes all cached resolved features.
* This clears everything from the resolved cache — `.geojson` features, resolved
* country-coder features, point circles, and aggregated location sets.
* The world feature ('Q2') is re-added automatically.
*
* @example
* ```ts
* loco.clearFeatures();
* // Cache now contains only the world feature (Q2)
* ```
*/
clearFeatures(): void {
this._resolved.clear();
this._setupWorld(); // will also call rebuildIndex()
}
/**
* Backward-compatibility alias to direct access the internal resolved-feature cache.
* If you use this to change the features, make sure to call `rebuildIndex()` after.
* @deprecated Use the LocationConflation cache management APIs instead (`addFeatures`, `removeFeatures`, `clearFeatures`).
*/
public get _cache(): Map<string, LocoFeature> {
return this._resolved;
}
/**
* Validates a location and returns its type and stable identifier.
* @param location - Location to validate (point, geojson filename, or country code)
* @returns Validated location result object
* @throws Throws Error if the given location is invalid.
*
* @example
* ```typescript
* // Point location with default 25km radius
* loco.validateLocation([8.67039, 49.41882]);
* // => { type: 'point', location: [8.67039, 49.41882], id: '[8.67039,49.41882]' }
*
* // Point location with custom radius
* loco.validateLocation([-77.0369, 38.9072, 10]);
* // => { type: 'point', location: [-77.0369, 38.9072, 10], id: '[-77.0369,38.9072,10]' }
*
* // Country code
* loco.validateLocation('de');
* // => { type: 'countrycoder', location: 'de', id: 'Q183' }
* loco.validateLocation('001');
* // => { type: 'countrycoder', location: '001', id: 'Q2' }
*
* // GeoJSON file
* loco.validateLocation('philly_metro.geojson');
* // => { type: 'geojson', location: 'philly_metro.geojson', id: 'philly_metro.geojson' }
* ```
*/
validateLocation(location: Location): ValidatedLocation {
// A [lon, lat] or [lon, lat, radius] point?
if (Array.isArray(location) && (location.length === 2 || location.length === 3)) {
const lon = location[0];
const lat = location[1];
const radius = location[2];
if (
this._isValidPoint([lon, lat]) &&
(location.length === 2 || (radius !== undefined && Number.isFinite(radius) && radius > 0))
) {
const id = '[' + location.toString() + ']';
return { type: 'point', location, id };
}
// A custom .geojson filename?
} else if (typeof location === 'string' && /^\S+\.geojson$/i.test(location)) {
const id = location.toLowerCase();
if (this._resolved.has(id)) {
return { type: 'geojson', location, id };
}
// A country-coder identifier?
} else if (typeof location === 'string' || typeof location === 'number') {
// a country-coder value?
const feature = CountryCoder.feature(location);
if (feature) {
// Normalize CountryCoder location id by using the returned Wikidata QID.
// It's the one property that everything in CountryCoder is guaranteed to have,
// and it allows us to match 'Q2' World and resolve it to our own world polygon.
const id = feature.properties.wikidata;
return { type: 'countrycoder', location, id };
}
}
throw new Error(`validateLocation: Invalid location: "${location}".`);
}
/**
* Resolves a location to a GeoJSON Feature.
* @param location - Location to resolve
* @returns Resolved location result object, including the GeoJSON Feature
* @throws Throws Error if the given location is invalid or cannot be resolved.
*
* @example
* ```typescript
* // Resolve a point location to a circular polygon
* const result = loco.resolveLocation([8.67039, 49.41882]);
* // result.feature is a GeoJSON Feature with a circular Polygon geometry
*
* // Resolve a country code
* const germany = loco.resolveLocation('de');
* // germany.feature is a GeoJSON Feature with Germany's boundary
*
* // Resolve a custom GeoJSON file
* const metro = loco.resolveLocation('philly_metro.geojson');
* // metro.feature is the pre-loaded GeoJSON Feature
* ```
*/
resolveLocation(location: Location): ResolvedLocation {
const valid = this.validateLocation(location);
const id = valid.id;
// Return an already-resolved result from cache if we can.
// This will hit cache for:
// - all custom .geojson
// - previously generated point-radius
// - previously seen country coder locations
// - our custom 'Q2' World location
if (this._resolved.has(id)) {
return { ...valid, feature: this._resolved.get(id)! };
}
// A newly seen [lon, lat] or [lon, lat, radius] point?
if (valid.type === 'point' && Array.isArray(location)) {
const lon = location[0];
const lat = location[1];
const radius = location[2] || 25; // km
const EDGES = 10;
const PRECISION = 3;
const area = Math.PI * radius * radius; // km²
const feature: LocoFeature = precision(
{
type: 'Feature',
id,
properties: { id, area: Number(area.toFixed(2)) },
geometry: circleToPolygon([lon, lat], radius * 1000, EDGES), // km to m
},
PRECISION
);
this._resolved.set(id, feature);
return { ...valid, feature };
}
// A newly seen country-coder identifier?
if (valid.type === 'countrycoder') {
// id is a wikidata QID that validateLocation already confirmed exists in CountryCoder
const ccFeature = CountryCoder.feature(id)!;
const ccProps = ccFeature.properties;
let geometry: GeoJSON.Polygon | GeoJSON.MultiPolygon | undefined;
// -> This block of code is weird and requires some explanation. <-
// CountryCoder includes higher level features which are made up of members.
// These features don't have their own geometry, but CountryCoder provides an
// `aggregateFeature` method to combine these members into a MultiPolygon.
// In the past, Turf/JSTS/martinez could not handle the aggregated features,
// so we'd iteratively union them all together. (this was slow)
// But now mfogel/polygon-clipping handles these MultiPolygons like a boss.
// This approach also has the benefit of removing all the internal borders and
// simplifying the regional polygons a lot.
if (Array.isArray(ccProps.members)) {
const aggregate = CountryCoder.aggregateFeature(id);
if (aggregate) {
const clipped = _clip([{
type: 'Feature',
id: '',
properties: {} as LocoProperties,
geometry: aggregate.geometry as GeoJSON.Polygon | GeoJSON.MultiPolygon,
}], 'UNION');
if (clipped) {
geometry = clipped.geometry;
}
}
}
// Clone geometry so we never hold a reference to CountryCoder's internal data.
// Skip the clone for aggregate features — clip() already produced a fresh geometry.
if (!geometry) {
geometry = structuredClone(ccFeature.geometry) as GeoJSON.Polygon | GeoJSON.MultiPolygon;
}
// Ensure `area` property exists
const area = _getArea(geometry);
const feature: LocoFeature = {
type: 'Feature',
id,
properties: { id, area } as LocoProperties,
geometry,
};
this._resolved.set(id, feature);
return { ...valid, feature };
}
throw new Error(`resolveLocation: Couldn't resolve location "${location}".`);
}
/**
* Validates a locationSet and returns its stable identifier.
* @param locationSet - LocationSet with include/exclude arrays
* @returns Validated locationSet result object
* @throws Throws Error if any referenced location is invalid, or locationSet has no include.
*
* @example
* ```typescript
* // Include multiple countries
* loco.validateLocationSet({ include: ['de', 'fr', 'it'] });
* // => { type: 'locationset', locationSet: {...}, id: '+[Q183,Q142,Q38]' }
*
* // Include with exclusions
* loco.validateLocationSet({
* include: ['de'],
* exclude: ['de-berlin.geojson']
* });
* // => { type: 'locationset', locationSet: {...}, id: '+[Q183]-[de-berlin.geojson]' }
*
* // Mix different location types
* loco.validateLocationSet({
* include: ['us', [8.67039, 49.41882], 'philly_metro.geojson']
* });
* ```
*/
validateLocationSet(locationSet?: LocationSet): ValidatedLocationSet {
locationSet = locationSet || {};
const validator = this.validateLocation.bind(this);
const include = (locationSet.include || []).map(validator) as ValidatedLocation[];
const exclude = (locationSet.exclude || []).map(validator) as ValidatedLocation[];
if (!include.length) {
throw new Error('validateLocationSet: LocationSet includes nothing.');
}
// Generate stable identifier
include.sort(_sortLocations);
let id = '+[' + include.map((d) => d.id).join(',') + ']';
if (exclude.length) {
exclude.sort(_sortLocations);
id += '-[' + exclude.map((d) => d.id).join(',') + ']';
}
return { type: 'locationset', locationSet, id };
}
/**
* Resolves a locationSet to a GeoJSON Feature by combining included/excluded regions.
* @param locationSet - LocationSet with include/exclude arrays
* @returns Resolved locationSet result object, including the GeoJSON Feature
* @throws Throws Error if any referenced location is invalid, or locationSet has no include.
*
* @example
* ```typescript
* // Combine multiple countries into one feature
* const benelux = loco.resolveLocationSet({
* include: ['be', 'nl', 'lu']
* });
* // benelux.feature is a GeoJSON Feature with combined boundaries
*
* // Germany excluding Berlin
* const germanyNoCapital = loco.resolveLocationSet({
* include: ['de'],
* exclude: ['de-berlin.geojson']
* });
* // Result is Germany with Berlin cut out
*
* // Complex region definition
* const customRegion = loco.resolveLocationSet({
* include: ['us-ca', 'us-or', 'us-wa'],
* exclude: [[8.67039, 49.41882, 50]]
* });
* // West coast states minus a 50km circle
* ```
*/
resolveLocationSet(locationSet?: LocationSet): ResolvedLocationSet {
locationSet = locationSet || {};
const valid = this.validateLocationSet(locationSet);
const id = valid.id;
// Return a result from cache if we can
if (this._resolved.has(id)) {
return { ...valid, feature: this._resolved.get(id)! };
}
const resolver = this.resolveLocation.bind(this);
const includes = (locationSet.include || []).map(resolver) as ResolvedLocation[];
const excludes = (locationSet.exclude || []).map(resolver) as ResolvedLocation[];
// Return quickly if it's just a single included location.
if (includes.length === 1 && excludes.length === 0) {
return { ...valid, feature: includes[0]!.feature };
}
// Calculate unions
const includeGeoJSON = _clip(includes.map((d) => d.feature), 'UNION')!;
const excludeGeoJSON = _clip(excludes.map((d) => d.feature), 'UNION');
// Calculate difference, update `area` and return result
const resultGeoJSON = excludeGeoJSON ? _clip([includeGeoJSON, excludeGeoJSON], 'DIFFERENCE')! : includeGeoJSON;
resultGeoJSON.id = id;
resultGeoJSON.properties = { id, area: _getArea(resultGeoJSON.geometry) };
this._resolved.set(id, resultGeoJSON);
return { ...valid, feature: resultGeoJSON };
}
/**
* Registers `locationSet`-bearing objects so that {@link locationSetsAt} can tell you
* which of them cover a given point.
*
* For each object, this method:
* 1. Validates the `locationSet` and assigns its stable `locationSetID` (e.g. `'+[Q183]'`).
* 2. Walks the `include` / `exclude` components and records them in an inverted index
* (component `locationID` → set of `locationSetID`s that reference it).
* 3. Sums the approximate areas of the `include` components so results can be ranked
* smallest-first.
*
* Unlike the single-item `validate*` / `resolve*` methods, this method is **tolerant** of
* bad input so that a batch of thousands of presets won't be rejected over a single typo:
* - Objects with a missing, empty, or invalid `locationSet` fall back to world (`+[Q2]`).
* - Individual invalid `include` / `exclude` components are silently ignored.
*
* This method **accumulates** — calling it multiple times (e.g. for different data sources)
* inserts all locationSets into the same index. The spatial index is rebuilt automatically.
* To reset the index entirely, create a new `LocationConflation` instance.
*
* Note: this does **not** resolve each locationSet to a combined polygon — that expensive
* clipping step is deferred to {@link resolveLocationSet}, called only when the caller
* actually needs the GeoJSON geometry.
*
* @param objects - Objects with a `locationSet` property to index.
* @returns The same array of objects, narrowed to guarantee `locationSetID` is set.
*
* @example
* ```ts
* const presets = [
* { id: 'amenity/cafe', locationSet: { include: ['de'] } },
* { id: 'amenity/atm', locationSet: { include: ['001'] } },
* ];
* loco.registerLocationSets(presets);
* // presets[0].locationSetID === '+[Q183]'
* // presets[1].locationSetID === '+[Q2]'
* ```
*/
registerLocationSets<T extends HasLocationSet>(objects: T[]): (T & HasLocationSetID)[] {
if (!Array.isArray(objects)) return [];
for (const obj of objects) {
// Resolve the locationSet to a stable id; fall back to world on any problem
// (missing, empty include, or all-invalid components).
let locationSet: LocationSet = obj.locationSet ?? {};
let locationSetID: LocationSetID;
try {
locationSetID = this.validateLocationSet(locationSet).id;
} catch {
locationSet = { include: ['Q2'] };
locationSetID = '+[Q2]';
}
obj.locationSet = locationSet;
obj.locationSetID = locationSetID;
// If we've already registered this exact locationSetID, skip the inner work.
// The object has already gotten its locationSetID assigned above.
if (this._registered.has(locationSetID)) continue;
let area = 0;
for (const location of (locationSet.include ?? [])) {
// Silently skip invalid/unresolvable locations in batch mode.
let resolved: ResolvedLocation;
try {
resolved = this.resolveLocation(location);
} catch {
continue;
}
const locationID: LocationID = resolved.id;
area += resolved.feature.properties.area;
let s = this._setsIncluding.get(locationID);
if (!s) { s = new Set(); this._setsIncluding.set(locationID, s); }
s.add(locationSetID);
}
for (const location of (locationSet.exclude ?? [])) {
// Silently skip invalid/unresolvable locations in batch mode.
let resolved: ResolvedLocation;
try {
resolved = this.resolveLocation(location);
} catch {
continue;
}
const locationID: LocationID = resolved.id;
let s = this._setsExcluding.get(locationID);
if (!s) { s = new Set(); this._setsExcluding.set(locationID, s); }
s.add(locationSetID);
}
this._registered.set(locationSetID, area);
}
this.rebuildIndex();
return objects as unknown as (T & HasLocationSetID)[];
}
/**
* Rebuilds the internal spatial index from the current cache and inverted index.
*
* Normally you don't need to call this yourself — {@link registerLocationSets},
* {@link addFeatures}, {@link removeFeatures}, and {@link clearFeatures} all rebuild
* the index automatically when appropriate. Call it manually only if you've mutated
* the cache through the (deprecated) `_cache` getter.
*/
rebuildIndex(): void {
// Collect all unique component locationIDs referenced by the inverted index.
const allLocationIDs = new Set([
...this._setsIncluding.keys(),
...this._setsExcluding.keys(),
]);
// Only custom .geojson features (ids ending in `.geojson`) and point-radius features
// (ids starting with `[`) go into our which-polygon index. CountryCoder regions are
// deliberately skipped here because CountryCoder already provides its own optimized
// spatial index via `featuresContaining()`, which we use directly in `locationSetsAt`.
const features: Array<{ type: 'Feature'; geometry: GeoJSON.Polygon | GeoJSON.MultiPolygon; properties: { id: LocationID } }> = [];
for (const locationID of allLocationIDs) {
if (!locationID.startsWith('[') && !/\.geojson$/i.test(locationID)) continue;
const feature = this._resolved.get(locationID);
if (feature) {
features.push({
type: 'Feature',
geometry: feature.geometry,
properties: { id: locationID },
});
}
}
this._spatialIndex = whichPolygon({ type: 'FeatureCollection', features });
}
/**
* Returns the registered locationSets that cover the given point,
* mapped to their approximate area in km².
*
* Uses the inverted spatial index built by {@link registerLocationSets} — no polygon clipping is
* performed. For country-coder regions, CountryCoder's built-in spatial index is used.
* For custom `.geojson` and point-radius features, our local which-polygon index is used.
*
* The returned `Map` gives callers O(1) `has(locationSetID)` membership tests (the common
* "is this locationSet valid here?" check).
* Results are not sorted — if you need them ordered, sort `[...result.entries()]` by value.
*
* Call {@link resolveLocationSet} on any returned ID if you need the actual GeoJSON feature.
*
* @param loc - `[longitude, latitude]` coordinate to query.
* @returns Map of locationSetID → approximate area (km²), for all sets valid at the point.
*
* @example
* ```ts
* loco.registerLocationSets(presets);
* const hits = loco.locationSetsAt([-75.16, 39.95]);
* if (hits.has('+[Q30]')) { ... }
* // Iterate smallest-first:
* const sorted = [...hits.entries()].sort((a, b) => a[1] - b[1]);
* ```
*/
locationSetsAt(loc: Vec2): Map<LocationSetID, number> {
const results = new Map<LocationSetID, number>();
const isValidPoint = this._isValidPoint(loc);
// Two-pass design:
// 1. For each component location covering the point, walk the inverted index to
// collect the locationSetIDs that include or exclude it.
// 2. Apply exclusions (a locationSet that excludes any covering location is removed).
//
// Location lookup is split across two spatial indexes because CountryCoder regions
// use CountryCoder's own index, while custom .geojson and point-radius features use ours.
// Unlike some earlier designs, we do NOT put resolved locationSet polygons into
// which-polygon — membership is always derived from location hits via the inverted
// index. This keeps the spatial index small and avoids needing to resolve every
// locationSet to a GeoJSON polygon, which is expensive.
const toExclude = new Set<LocationSetID>();
// Search the inverted index for occurrances of the given locationID.
const gather = (locationID: LocationID): void => {
for (const id of (this._setsIncluding.get(locationID) ?? [])) {
results.set(id, this._registered.get(id) ?? Infinity);
}
for (const id of (this._setsExcluding.get(locationID) ?? [])) {
toExclude.add(id);
}
};
// Country-coder components: delegate to CountryCoder's own spatial index.
// CountryCoder models "world" as an aggregation of known administrative features,
// which can be empty in places like open ocean/poles. We still guarantee `+[Q2]`
// for any valid coordinate via special handling below.
if (isValidPoint) {
try {
for (const ccFeature of CountryCoder.featuresContaining(loc)) {
gather(ccFeature.properties.wikidata);
}
} catch {
// Defensive: treat lookup failures as "no CountryCoder matches".
}
}
// Custom .geojson and point-radius components: use our which-polygon index
// which-polygon returns null (not []) when multi=true and no matches
if (isValidPoint && this._spatialIndex) {
for (const props of (this._spatialIndex(loc, true) ?? [])) {
gather(props.id);
}
}
// Apply exclusions
for (const id of toExclude) {
results.delete(id);
}
// Our world means "any valid lon/lat in [-180..180] x [-90..90]", not just
// CountryCoder's aggregated administrative coverage. Ensure `+[Q2]` is present
// for valid coordinates unless that locationSet was explicitly excluded.
if (isValidPoint && !toExclude.has('+[Q2]')) {
const worldArea = this._registered.get('+[Q2]');
if (worldArea !== undefined) {
results.set('+[Q2]', worldArea);
}
}
return results;
}
/**
* Returns the approximate area (in km²) of a registered locationSet.
* The area is the sum of `include` location areas computed during
* {@link registerLocationSets} — it does not subtract `exclude` areas.
* Returns `undefined` if the locationSet has not been registered.
*
* @param locationSetID - Stable locationSet identifier, e.g. `'+[Q183]'`.
* @returns Approximate area in km², or `undefined` if not registered.
*
* @example
* ```ts
* loco.registerLocationSets([{ locationSet: { include: ['de'] } }]);
* loco.getLocationSetArea('+[Q183]'); // ~357000
* ```
*/
getLocationSetArea(locationSetID: LocationSetID): number | undefined {
return this._registered.get(locationSetID);
}
/**
* Convenience method to pretty-stringify an object.
* @param obj - Object to stringify
* @param options - Stringify options
* @returns Pretty-formatted JSON string
*
* @example
* ```typescript
* const result = loco.resolveLocation('de');
* console.log(LocationConflation.stringify(result.feature));
* // Outputs a compact, readable JSON representation of the feature
*
* // Custom formatting options
* console.log(LocationConflation.stringify(result.feature, { indent: 2, maxLength: 80 }));
* ```
*/
static stringify(obj: unknown, options?: StringifyOptions): string {
return prettyStringify(obj, options);
}
}
/** The two polygon clipping operations used internally. */
type ClipOperation = 'UNION' | 'DIFFERENCE';
/**
* Wraps the polyclip-ts library and returns a GeoJSON feature.
* @param features - Array of features to clip
* @param which - Operation type (UNION or DIFFERENCE)
* @returns Clipped GeoJSON feature or null if features array is empty
*/
function _clip(features: LocoFeature[], which: ClipOperation): LocoFeature | null {
if (!Array.isArray(features) || !features.length) return null;
const fn = which === 'UNION' ? Polyclip.union : Polyclip.difference;
const first = features[0]!.geometry.coordinates as Geom;
const rest: Geom[] = [];
for (let i = 1; i < features.length; i++) {
rest.push(features[i]!.geometry.coordinates as Geom);
}
const coords = fn(first, ...rest);
return {
type: 'Feature',
properties: {} as LocoProperties,
geometry: {
type: whichType(coords),
coordinates: coords,
} as GeoJSON.Polygon | GeoJSON.MultiPolygon,
id: '',
};
// is this a Polygon or a MultiPolygon?
function whichType(coords: unknown): 'Polygon' | 'MultiPolygon' {
const a = Array.isArray(coords);
const b = a && Array.isArray(coords[0]);
const c = b && Array.isArray(coords[0][0]);
const d = c && Array.isArray(coords[0][0][0]);
return d ? 'MultiPolygon' : 'Polygon';
}
}
/**
* Sorting function for locations to generate deterministic IDs.
* Note that sorting the include/exclude lists is ok because their contents end up unioned together.
* @param a - First location
* @param b - Second location
* @returns Sort order
*/
function _sortLocations(a: ValidatedLocation, b: ValidatedLocation): number {
const rank = { countrycoder: 1, geojson: 2, point: 3 };
const aRank = rank[a.type];
const bRank = rank[b.type];
return aRank > bRank ? 1 : aRank < bRank ? -1 : a.id.localeCompare(b.id);
}
/**
* Compute the rough area of a GeoJSON geometry, in km².
* We ensure that all resolved GeoJSON features have an `area` property.
* The purpose of the `area` property is to enable rough size comparisons.
* @param geom - The geometry to compute the area for
* @returns Area, in km², and truncated to 2 decimal places
*/
function _getArea(geom: GeoJSON.Geometry): number {
return Number((calcArea.geometry(geom) / 1e6).toFixed(2)); // m² to km²
}
export default LocationConflation;