UNPKG

@rapideditor/location-conflation

Version:

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

359 lines (356 loc) 11.6 kB
// 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