@rapideditor/location-conflation
Version:
Define complex geographic regions by including and excluding country codes and geojson shapes
359 lines (356 loc) • 11.6 kB
JavaScript
// src/location-conflation.ts
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";
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 = precision({
type: "Feature",
id,
properties: { id, area: Number(area.toFixed(2)) },
geometry: circleToPolygon([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 = whichPolygon({ 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 prettyStringify(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((calcArea.geometry(geom) / 1e6).toFixed(2));
}
var location_conflation_default = LocationConflation;
export {
location_conflation_default as default,
LocationConflation
};
//# debugId=43514DCBB5509B9364756E2164756E21
//# sourceMappingURL=location-conflation.mjs.map