UNPKG

@rapideditor/location-conflation

Version:

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

423 lines (419 loc) 14 kB
var __create = Object.create; var __getProtoOf = Object.getPrototypeOf; var __defProp = Object.defineProperty; var __getOwnPropNames = Object.getOwnPropertyNames; var __getOwnPropDesc = Object.getOwnPropertyDescriptor; var __hasOwnProp = Object.prototype.hasOwnProperty; function __accessProp(key) { return this[key]; } var __toESMCache_node; var __toESMCache_esm; var __toESM = (mod, isNodeMode, target) => { var canCache = mod != null && typeof mod === "object"; if (canCache) { var cache = isNodeMode ? __toESMCache_node ??= new WeakMap : __toESMCache_esm ??= new WeakMap; var cached = cache.get(mod); if (cached) return cached; } target = mod != null ? __create(__getProtoOf(mod)) : {}; const to = isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target; for (let key of __getOwnPropNames(mod)) if (!__hasOwnProp.call(to, key)) __defProp(to, key, { get: __accessProp.bind(mod, key), enumerable: true }); if (canCache) cache.set(mod, to); return to; }; var __toCommonJS = (from) => { var entry = (__moduleCache ??= new WeakMap).get(from), desc; if (entry) return entry; entry = __defProp({}, "__esModule", { value: true }); if (from && typeof from === "object" || typeof from === "function") { for (var key of __getOwnPropNames(from)) if (!__hasOwnProp.call(entry, key)) __defProp(entry, key, { get: __accessProp.bind(from, key), enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable }); } __moduleCache.set(from, entry); return entry; }; var __moduleCache; var __returnValue = (v) => v; function __exportSetter(name, newValue) { this[name] = __returnValue.bind(null, newValue); } var __export = (target, all) => { for (var name in all) __defProp(target, name, { get: all[name], enumerable: true, configurable: true, set: __exportSetter.bind(all, name) }); }; // src/location-conflation.ts var exports_location_conflation = {}; __export(exports_location_conflation, { default: () => location_conflation_default, LocationConflation: () => LocationConflation }); module.exports = __toCommonJS(exports_location_conflation); var CountryCoder = __toESM(require("@rapideditor/country-coder")); var Polyclip = __toESM(require("polyclip-ts")); var import_geojson_area = __toESM(require("@mapbox/geojson-area")); var import_circle_to_polygon = __toESM(require("circle-to-polygon")); var import_geojson_precision = __toESM(require("geojson-precision")); var import_json_stringify_pretty_compact = __toESM(require("json-stringify-pretty-compact")); var import_which_polygon = __toESM(require("which-polygon")); class LocationConflation { _resolved; _registered; _setsIncluding; _setsExcluding; _spatialIndex; constructor(fc) { this._resolved = new Map; this._registered = new Map; this._setsIncluding = new Map; this._setsExcluding = new Map; this._spatialIndex = null; if (fc) { this.addFeatures(fc); } this._setupWorld(); } _setupWorld() { const WORLD_GEOMETRY = { type: "Polygon", coordinates: [[[-180, -90], [180, -90], [180, 90], [-180, 90], [-180, -90]]] }; const WORLD_LOCATION = { type: "Feature", id: "Q2", properties: { id: "Q2", area: _getArea(WORLD_GEOMETRY) }, geometry: WORLD_GEOMETRY }; const WORLD_LOCATIONSET = { locationSet: { include: ["Q2"] } }; this._resolved.set("Q2", WORLD_LOCATION); this.registerLocationSets([WORLD_LOCATIONSET]); } _isValidPoint(loc) { const [lon, lat] = loc; return Number.isFinite(lon) && lon >= -180 && lon <= 180 && Number.isFinite(lat) && lat >= -90 && lat <= 90; } addFeatures(fc) { if (fc?.type !== "FeatureCollection" || !Array.isArray(fc.features)) return; for (const feature2 of fc.features) { const props = feature2.properties ?? {}; let id = feature2.id ?? props["id"]; if (!id || !/^\S+\.geojson$/i.test(String(id))) continue; id = String(id).toLowerCase(); const geometry = feature2.geometry; const existingArea = props["area"]; const area = existingArea || _getArea(geometry); const lcFeature = { type: "Feature", id, properties: { ...props, id, area }, geometry }; this._resolved.set(id, lcFeature); } this.rebuildIndex(); } removeFeatures(...ids) { for (const id of ids) { if (!/^\S+\.geojson$/i.test(id)) continue; this._resolved.delete(id.toLowerCase()); } this.rebuildIndex(); } clearFeatures() { this._resolved.clear(); this._setupWorld(); } get _cache() { return this._resolved; } validateLocation(location) { 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 }; } } else if (typeof location === "string" && /^\S+\.geojson$/i.test(location)) { const id = location.toLowerCase(); if (this._resolved.has(id)) { return { type: "geojson", location, id }; } } else if (typeof location === "string" || typeof location === "number") { const feature2 = CountryCoder.feature(location); if (feature2) { const id = feature2.properties.wikidata; return { type: "countrycoder", location, id }; } } throw new Error(`validateLocation: Invalid location: "${location}".`); } resolveLocation(location) { const valid = this.validateLocation(location); const id = valid.id; if (this._resolved.has(id)) { return { ...valid, feature: this._resolved.get(id) }; } if (valid.type === "point" && Array.isArray(location)) { const lon = location[0]; const lat = location[1]; const radius = location[2] || 25; const EDGES = 10; const PRECISION = 3; const area = Math.PI * radius * radius; const feature2 = import_geojson_precision.default({ type: "Feature", id, properties: { id, area: Number(area.toFixed(2)) }, geometry: import_circle_to_polygon.default([lon, lat], radius * 1000, EDGES) }, PRECISION); this._resolved.set(id, feature2); return { ...valid, feature: feature2 }; } if (valid.type === "countrycoder") { const ccFeature = CountryCoder.feature(id); const ccProps = ccFeature.properties; let geometry; if (Array.isArray(ccProps.members)) { const aggregate = CountryCoder.aggregateFeature(id); if (aggregate) { const clipped = _clip([{ type: "Feature", id: "", properties: {}, geometry: aggregate.geometry }], "UNION"); if (clipped) { geometry = clipped.geometry; } } } if (!geometry) { geometry = structuredClone(ccFeature.geometry); } const area = _getArea(geometry); const feature2 = { type: "Feature", id, properties: { id, area }, geometry }; this._resolved.set(id, feature2); return { ...valid, feature: feature2 }; } throw new Error(`resolveLocation: Couldn't resolve location "${location}".`); } validateLocationSet(locationSet) { locationSet = locationSet || {}; const validator = this.validateLocation.bind(this); const include = (locationSet.include || []).map(validator); const exclude = (locationSet.exclude || []).map(validator); if (!include.length) { throw new Error("validateLocationSet: LocationSet includes nothing."); } 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 }; } resolveLocationSet(locationSet) { locationSet = locationSet || {}; const valid = this.validateLocationSet(locationSet); const id = valid.id; if (this._resolved.has(id)) { return { ...valid, feature: this._resolved.get(id) }; } const resolver = this.resolveLocation.bind(this); const includes = (locationSet.include || []).map(resolver); const excludes = (locationSet.exclude || []).map(resolver); if (includes.length === 1 && excludes.length === 0) { return { ...valid, feature: includes[0].feature }; } const includeGeoJSON = _clip(includes.map((d) => d.feature), "UNION"); const excludeGeoJSON = _clip(excludes.map((d) => d.feature), "UNION"); 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 }; } registerLocationSets(objects) { if (!Array.isArray(objects)) return []; for (const obj of objects) { let locationSet = obj.locationSet ?? {}; let locationSetID; try { locationSetID = this.validateLocationSet(locationSet).id; } catch { locationSet = { include: ["Q2"] }; locationSetID = "+[Q2]"; } obj.locationSet = locationSet; obj.locationSetID = locationSetID; if (this._registered.has(locationSetID)) continue; let area = 0; for (const location of locationSet.include ?? []) { let resolved; try { resolved = this.resolveLocation(location); } catch { continue; } const 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 ?? []) { let resolved; try { resolved = this.resolveLocation(location); } catch { continue; } const 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; } rebuildIndex() { const allLocationIDs = new Set([ ...this._setsIncluding.keys(), ...this._setsExcluding.keys() ]); const features = []; for (const locationID of allLocationIDs) { if (!locationID.startsWith("[") && !/\.geojson$/i.test(locationID)) continue; const feature2 = this._resolved.get(locationID); if (feature2) { features.push({ type: "Feature", geometry: feature2.geometry, properties: { id: locationID } }); } } this._spatialIndex = import_which_polygon.default({ type: "FeatureCollection", features }); } locationSetsAt(loc) { const results = new Map; const isValidPoint = this._isValidPoint(loc); const toExclude = new Set; const gather = (locationID) => { 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); } }; if (isValidPoint) { try { for (const ccFeature of CountryCoder.featuresContaining(loc)) { gather(ccFeature.properties.wikidata); } } catch {} } if (isValidPoint && this._spatialIndex) { for (const props of this._spatialIndex(loc, true) ?? []) { gather(props.id); } } for (const id of toExclude) { results.delete(id); } if (isValidPoint && !toExclude.has("+[Q2]")) { const worldArea = this._registered.get("+[Q2]"); if (worldArea !== undefined) { results.set("+[Q2]", worldArea); } } return results; } getLocationSetArea(locationSetID) { return this._registered.get(locationSetID); } static stringify(obj, options) { return import_json_stringify_pretty_compact.default(obj, options); } } function _clip(features, which) { if (!Array.isArray(features) || !features.length) return null; const fn = which === "UNION" ? Polyclip.union : Polyclip.difference; const first = features[0].geometry.coordinates; const rest = []; for (let i = 1;i < features.length; i++) { rest.push(features[i].geometry.coordinates); } const coords = fn(first, ...rest); return { type: "Feature", properties: {}, geometry: { type: whichType(coords), coordinates: coords }, id: "" }; function whichType(coords2) { const a = Array.isArray(coords2); const b = a && Array.isArray(coords2[0]); const c = b && Array.isArray(coords2[0][0]); const d = c && Array.isArray(coords2[0][0][0]); return d ? "MultiPolygon" : "Polygon"; } } function _sortLocations(a, b) { 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); } function _getArea(geom) { return Number((import_geojson_area.default.geometry(geom) / 1e6).toFixed(2)); } var location_conflation_default = LocationConflation; //# debugId=7912B099A0052E7564756E2164756E21 //# sourceMappingURL=location-conflation.cjs.map