UNPKG

@ahamove/polygon-lookup

Version:

A fork from pelias/polygon-lookup with updated dependencies

300 lines 11.9 kB
/** * Exports a `PolygonLookup` class, which constructs a data-structure for * quickly finding the polygon that a point intersects in a (potentially very * large) set. */ import booleanPointInPolygon from "@turf/boolean-point-in-polygon"; import * as turf from "@turf/helpers"; import Flatbush from "flatbush"; import Rbush from "rbush"; import { getBoundingBox } from "./utils.js"; /** * Calculate point in polygon intersection, accounting for any holes. * @private * @param point - The [x, y] coordinate to test. * @param polygon - A GeoJSON Feature with Polygon geometry. * @returns True if the point is inside the polygon (and not in any holes), false otherwise. */ function pointInPolygonWithHoles(point, polygon) { const { coordinates } = polygon.geometry; const mainPolygon = coordinates[0]; if (!mainPolygon) { return false; } // Create a GeoJSON point using turf.point const pointGeoJSON = turf.point(point); // Create a GeoJSON polygon for the main polygon using turf.polygon const mainPolygonGeoJSON = turf.polygon([mainPolygon]); if (booleanPointInPolygon(pointGeoJSON, mainPolygonGeoJSON)) { for (let subPolyInd = 1; subPolyInd < coordinates.length; subPolyInd++) { const holePolygon = coordinates[subPolyInd]; if (!holePolygon) continue; // Create a GeoJSON polygon for each hole using turf.polygon const holePolygonGeoJSON = turf.polygon([holePolygon]); if (booleanPointInPolygon(pointGeoJSON, holePolygonGeoJSON)) { return false; } } return true; } return false; } class PolygonLookup { /** * The type of spatial index being used. */ indexType; /** * Optional custom node size for the spatial index. */ nodeSize; /** * R-tree spatial index containing bounding boxes with polygon references. * Used when indexType is 'rbush'. */ rtree = null; /** * Flatbush spatial index. * Used when indexType is 'flatbush'. */ flatIndex = null; /** * Array of bounding box data for Flatbush index lookup. * Flatbush returns indices, so we need to store bbox objects separately. */ bboxData = []; /** * Array of indexed GeoJSON polygon features. */ polygons = []; /** * Create a new PolygonLookup instance. * * @param featureCollection - Optional GeoJSON FeatureCollection to index immediately * @param options - Configuration options * @param options.indexType - Spatial index backend ('rbush' or 'flatbush') * @param options.nodeSize - Node size for spatial index (affects performance) * @throws Error if featureCollection is invalid * * @example * // Default: RBush backend * const lookup = new PolygonLookup(geojson); * * @example * // Performance mode: Flatbush backend * const lookup = new PolygonLookup(geojson, { indexType: 'flatbush' }); */ constructor(featureCollection, options) { this.indexType = options?.indexType ?? "rbush"; this.nodeSize = options?.nodeSize ?? undefined; this.polygons = []; this.bboxData = []; if (featureCollection !== undefined) { this.loadFeatureCollection(featureCollection); } } /** * Build RBush spatial index from bounding boxes. * @private * @param bboxes - Array of bounding boxes with polyId references */ buildRbushIndex(bboxes) { const nodeSize = this.nodeSize !== undefined ? this.nodeSize : 9; this.rtree = new Rbush(nodeSize).load(bboxes); this.flatIndex = null; // Clear other index } /** * Build Flatbush spatial index from bounding boxes. * @private * @param bboxes - Array of bounding boxes with polyId references */ buildFlatbushIndex(bboxes) { const nodeSize = this.nodeSize !== undefined ? this.nodeSize : 16; this.flatIndex = new Flatbush(bboxes.length, nodeSize); // Add all bounding boxes for (let i = 0; i < bboxes.length; i++) { const bbox = bboxes[i]; this.flatIndex.add(bbox.minX, bbox.minY, bbox.maxX, bbox.maxY); } // Finalize index (required before searching) this.flatIndex.finish(); this.rtree = null; // Clear other index } /** * Search for bounding boxes using RBush index. * @private * @param x - The x-coordinate to search for * @param y - The y-coordinate to search for * @returns Array of bounding boxes that may contain the point */ searchRbush(x, y) { if (!this.rtree) { return []; } return this.rtree.search({ minX: x, minY: y, maxX: x, maxY: y }); } /** * Search for bounding boxes using Flatbush index. * @private * @param x - The x-coordinate to search for * @param y - The y-coordinate to search for * @returns Array of bounding boxes that may contain the point */ searchFlatbush(x, y) { if (!this.flatIndex) { return []; } // Flatbush returns indices, not the actual bbox objects const indices = this.flatIndex.search(x, y, x, y); // Map indices back to bbox objects return indices.map((idx) => this.bboxData[idx]); } /** * Internal helper method to return a single matching polygon. * @private * @param x - The x-coordinate to search for. * @param y - The y-coordinate to search for. * @returns The first polygon that intersects (x, y), or undefined if none found. */ searchForOnePolygon(x, y) { // find which bboxes contain the search point. their polygons _may_ intersect that point const bboxes = this.indexType === "flatbush" ? this.searchFlatbush(x, y) : this.searchRbush(x, y); const point = [x, y]; // get the polygon for each possibly matching polygon based on the searched bboxes const polygons = bboxes.map((bbox) => this.polygons[bbox.polyId]); return polygons.find((poly) => pointInPolygonWithHoles(point, poly)); } /** * Internal helper method to return multiple matching polygons, up to a given limit. * @private * @param x - The x-coordinate to search for. * @param y - The y-coordinate to search for. * @param limit - Maximum number of results to return. Use -1 for unlimited. * @returns A GeoJSON FeatureCollection containing matching polygons (up to limit). */ searchForMultiplePolygons(x, y, limit) { const safeLimit = limit === -1 ? Number.MAX_SAFE_INTEGER : limit; const point = [x, y]; const bboxes = this.indexType === "flatbush" ? this.searchFlatbush(x, y) : this.searchRbush(x, y); // get the polygon for each possibly matching polygon based on the searched bboxes let polygons = bboxes.map((bbox) => this.polygons[bbox.polyId]); // keep track of matches to avoid extra expensive calculations if limit reached let matchesFound = 0; // filter matching polygons, up to the limit polygons = polygons.filter((polygon) => { // short circuit if limit reached if (matchesFound >= safeLimit) { return false; } const intersects = pointInPolygonWithHoles(point, polygon); if (intersects) { matchesFound++; return true; } return false; }); // return all matching polygons as a GeoJSON FeatureCollection return { type: "FeatureCollection", features: polygons }; } /** * Find polygon(s) that a point intersects. Execute a bounding-box search to * narrow down the candidate polygons to a small subset, and then perform * additional point-in-polygon intersections to resolve any ambiguities. * * @param x - The x-coordinate of the point. * @param y - The y-coordinate of the point. * @param limit - Number of results to return (-1 to return all the results). * @returns If one or more bounding box intersections are found and limit is undefined, * return the first polygon that intersects (`x`, `y`); otherwise, `undefined`. * If a limit is passed in, return intersecting polygons as a GeoJSON FeatureCollection. */ search(x, y, limit) { if (limit === undefined) { return this.searchForOnePolygon(x, y); } return this.searchForMultiplePolygons(x, y, limit); } /** * Build spatial index from a GeoJSON FeatureCollection. * * MultiPolygons are automatically expanded into individual Polygons. * Properly handles polygons with holes. * * @param collection - GeoJSON FeatureCollection with Polygon or MultiPolygon features * @throws Error if collection is null, undefined, or missing features property */ loadFeatureCollection(collection) { if (!collection) { throw new Error("PolygonLookup.loadFeatureCollection: collection parameter is required"); } if (!collection.features) { throw new Error("PolygonLookup.loadFeatureCollection: collection must have a 'features' property"); } if (!Array.isArray(collection.features)) { throw new Error("PolygonLookup.loadFeatureCollection: collection.features must be an array"); } const bboxes = []; const polygons = []; let polyId = 0; const indexPolygon = (poly) => { polygons.push(poly); const coordinates = poly.geometry.coordinates[0]; if (!coordinates) return; const bbox = getBoundingBox(coordinates); bbox.polyId = polyId++; bboxes.push(bbox); }; const indexFeature = (poly) => { if (poly.geometry && poly.geometry.coordinates[0] !== undefined && poly.geometry.coordinates[0].length > 0) { switch (poly.geometry.type) { case "Polygon": { indexPolygon(poly); break; } case "MultiPolygon": { const multiPoly = poly; const childPolys = multiPoly.geometry.coordinates; for (let ind = 0; ind < childPolys.length; ind++) { const childPolyCoords = childPolys[ind]; if (!childPolyCoords) continue; const childPoly = { type: "Feature", properties: poly.properties, geometry: { type: "Polygon", coordinates: childPolyCoords } }; indexPolygon(childPoly); } break; } default: break; } } }; collection.features.forEach(indexFeature); // Store bbox data for later retrieval with flatbush this.bboxData = bboxes; this.polygons = polygons; // Build spatial index based on type if (this.indexType === "flatbush") { this.buildFlatbushIndex(bboxes); } else { this.buildRbushIndex(bboxes); } } } export default PolygonLookup; export { getBoundingBox } from "./utils.js"; //# sourceMappingURL=index.js.map