@rapideditor/location-conflation
Version:
Define complex geographic regions by including and excluding country codes and geojson shapes
423 lines (419 loc) • 14 kB
JavaScript
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