UNPKG

@rapideditor/location-conflation

Version:

Define complex geographic regions by including and excluding country codes and geojson shapes

881 lines (773 loc) 33.1 kB
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;