diff --git a/packages/turf-intersect-by-area-percentage/index.ts b/packages/turf-intersect-by-area-percentage/index.ts new file mode 100644 index 000000000..1037e8176 --- /dev/null +++ b/packages/turf-intersect-by-area-percentage/index.ts @@ -0,0 +1,122 @@ +import intersect from "@turf/intersect"; +import area from "@turf/area"; +import { + feature, + featureCollection, + isObject, +} from "@turf/helpers"; +import { + Feature, + Polygon, + MultiPolygon, +} from "geojson"; +import * as invariant from "@turf/invariant"; + +/** + * Checks if the intersection area between a target polygon (targetPolygon) + * and a test polygon (testPolygon) is greater than or equal to a specified + * threshold percentage of the test polygon's *own* area. + * + * @name intersectByAreaPercentage + * @param {Feature|Polygon|MultiPolygon} targetPolygon The target polygon feature. + * @param {Feature|Polygon|MultiPolygon} testPolygon The test polygon feature whose intersection area is being evaluated. + * @param {number} threshold The minimum required percentage (from 0.0 to 1.0) of testPolygon's area that must be inside targetPolygon. + * @returns {boolean} True if the ratio (intersectionArea / testPolygonArea) is >= threshold. + * Returns false if inputs are invalid, threshold is invalid, testPolygon has zero area and threshold > 0, + * or if an error occurs during calculation. Returns true if threshold is 0 and polygons do not intersect + * or if testPolygon has zero area. + * @example + * const poly1 = turf.polygon([[[-10, -10], [10, -10], [10, 10], [-10, 10], [-10, -10]]]); + * const poly2 = turf.polygon([[[0, 0], [20, 0], [20, 20], [0, 20], [0, 0]]]); // Overlaps 1/4 (25%) of poly1 + * const poly3 = turf.polygon([[[-5, -5], [5, -5], [5, 5], [-5, 5], [-5, -5]]]); // Fully contained in poly1 + * + * turf.intersectByAreaPercentage(poly1, poly2, 0.25); // => true (intersection is 100 area units, poly2 is 400 area units => 100/400 = 0.25) + * turf.intersectByAreaPercentage(poly1, poly2, 0.26); // => false + * turf.intersectByAreaPercentage(poly1, poly3, 0.99); // => true (intersection is 100% of poly3's area) + * turf.intersectByAreaPercentage(poly1, poly3, 0.0); // => true + */ +function intersectByAreaPercentage( + targetPolygon: Feature | Polygon | MultiPolygon, + testPolygon: Feature | Polygon | MultiPolygon, + threshold: number +): boolean { + // Basic validation + if (!targetPolygon || !testPolygon || typeof threshold !== "number" || threshold < 0 || threshold > 1) { + return false; + } + + // Use invariant.getGeom().type for safer type checking + let targetType: string; + let testType: string; + try { + targetType = invariant.getGeom(targetPolygon).type; + testType = invariant.getGeom(testPolygon).type; + } catch (e) { + return false; // Invalid geometry input + } + + if ( + (targetType !== "Polygon" && targetType !== "MultiPolygon") || + (testType !== "Polygon" && testType !== "MultiPolygon") + ) { + return false; + } + + try { + const testPolygonArea = area(testPolygon); + + // Handle zero area test polygon + if (testPolygonArea === 0) { + return threshold === 0; + } + + // Ensure inputs are valid Features for intersect. + // intersect expects Feature. + let targetFeature: Feature; + let testFeature: Feature; + + try { + const targetGeom = invariant.getGeom(targetPolygon); + const testGeom = invariant.getGeom(testPolygon); + + // Re-check types after getting geometry + if ((targetGeom.type !== "Polygon" && targetGeom.type !== "MultiPolygon") || (testGeom.type !== "Polygon" && testGeom.type !== "MultiPolygon")) { + return false; + } + + // If original input was Geometry, wrap in Feature. Otherwise, use the Feature. + targetFeature = targetGeom === targetPolygon ? feature(targetGeom) : targetPolygon as Feature; + testFeature = testGeom === testPolygon ? feature(testGeom) : testPolygon as Feature; + + } catch (e) { + return false; // Error during geometry retrieval or type check + } + + const fc = featureCollection([targetFeature, testFeature]); + + const intersectionResult = intersect(fc); + + // No intersection or only touch + if (intersectionResult === null) { + return threshold === 0; + } + + const intersectionArea = area(intersectionResult); + + // Handle zero area intersection (e.g., linear touch) + if (intersectionArea === 0) { + return threshold === 0; + } + + // Calculate overlap ratio relative to testPolygon's area + const overlapRatio = intersectionArea / testPolygonArea; + + return overlapRatio >= threshold; + } catch (e) { + // Treat calculation errors as 'condition not met' + // console.error(`Error during intersect/area calculation: ${e.message}`); + return false; + } +} + +export default intersectByAreaPercentage; \ No newline at end of file diff --git a/packages/turf-intersect-by-area-percentage/package.json b/packages/turf-intersect-by-area-percentage/package.json new file mode 100644 index 000000000..f76b2fad5 --- /dev/null +++ b/packages/turf-intersect-by-area-percentage/package.json @@ -0,0 +1,78 @@ +{ + "name": "@turf/intersect-by-area-percentage", + "version": "7.2.0", + "description": "Checks if the intersection area between two polygons meets a threshold percentage relative to the second polygon's area.", + "author": "Turf Authors", + "contributors": [ + "Gairo Peralta <@Gerion9>" + ], + "license": "MIT", + "bugs": { + "url": "https://github.com/Turfjs/turf/issues" + }, + "homepage": "https://github.com/Turfjs/turf/tree/master/packages/turf-intersect-by-area-percentage#readme", + "repository": { + "type": "git", + "url": "git://github.com/Turfjs/turf.git" + }, + "funding": "https://opencollective.com/turf", + "publishConfig": { + "access": "public" + }, + "keywords": [ + "turf", + "polygon", + "intersection", + "overlap", + "percentage", + "area" + ], + "type": "module", + "main": "dist/cjs/index.cjs", + "module": "dist/esm/index.js", + "types": "dist/esm/index.d.ts", + "exports": { + "./package.json": "./package.json", + ".": { + "import": { + "types": "./dist/esm/index.d.ts", + "default": "./dist/esm/index.js" + }, + "require": { + "types": "./dist/cjs/index.d.cts", + "default": "./dist/cjs/index.cjs" + } + } + }, + "sideEffects": false, + "files": [ + "dist" + ], + "scripts": { + "bench": "tsx bench.ts", + "build": "tsup --config ../../tsup.config.ts", + "docs": "tsx ../../scripts/generate-readmes.ts", + "test": "npm-run-all --npm-path npm test:*", + "test:tape": "tsx test.ts" + }, + "devDependencies": { + "@types/benchmark": "^2.1.5", + "@types/tape": "^4.13.4", + "benchmark": "^2.1.4", + "load-json-file": "^7.0.1", + "npm-run-all": "^4.1.5", + "tape": "^5.9.0", + "tsup": "^8.3.5", + "tsx": "^4.19.2", + "typescript": "^5.5.4", + "write-json-file": "^5.0.0" + }, + "dependencies": { + "@turf/area": "workspace:^", + "@turf/helpers": "workspace:^", + "@turf/intersect": "workspace:^", + "@turf/invariant": "workspace:^", + "@types/geojson": "^7946.0.10", + "tslib": "^2.8.1" + } +} \ No newline at end of file diff --git a/packages/turf-intersect-by-area-percentage/test.ts b/packages/turf-intersect-by-area-percentage/test.ts new file mode 100644 index 000000000..ab3f44d1f --- /dev/null +++ b/packages/turf-intersect-by-area-percentage/test.ts @@ -0,0 +1,84 @@ +import { test } from "tape"; +import { globSync } from "glob"; +import path from "path"; +import { fileURLToPath } from "url"; +import { loadJsonFileSync } from "load-json-file"; +import { polygon, multiPolygon, featureCollection } from "@turf/helpers"; +import intersectByAreaPercentage from "./index.js"; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); + +test("@turf/intersect-by-area-percentage", (t) => { + // Load test fixtures + const fixtures = globSync(path.join(__dirname, "test", "in", "*.json")).map( + (filepath) => { + const name = path.parse(filepath).name; + const geojson = loadJsonFileSync(filepath); + return { + name, + geojson, + filepath, + }; + } + ); + + for (const { name, geojson } of fixtures) { + const { features } = geojson; + const [poly1, poly2, poly3, polyNoOverlap, multiPoly1] = features; + + // Test cases based on fixture data + t.true( + intersectByAreaPercentage(poly1, poly3, 0.2), + `${name}: poly1 vs poly3 (threshold 0.2) - Should intersect >= 20%` + ); + t.false( + intersectByAreaPercentage(poly1, poly2, 0.3), + `${name}: poly1 vs poly2 (threshold 0.3) - Should intersect < 30%` + ); + t.true( + intersectByAreaPercentage(poly1, poly2, 0.25), + `${name}: poly1 vs poly2 (threshold 0.25) - Should intersect >= 25% (exact)` + ); + t.false( + intersectByAreaPercentage(poly1, polyNoOverlap, 0.01), + `${name}: poly1 vs polyNoOverlap (threshold 0.01) - Should not intersect` + ); + t.true( + intersectByAreaPercentage(poly1, polyNoOverlap, 0.0), + `${name}: poly1 vs polyNoOverlap (threshold 0.0) - Should be true for zero threshold` + ); + t.true( + intersectByAreaPercentage(poly1, poly1, 1.0), + `${name}: poly1 vs poly1 (threshold 1.0) - Should be 100% overlap` + ); + t.false( + intersectByAreaPercentage(poly1, poly1, 1.1), + `${name}: poly1 vs poly1 (invalid threshold > 1.0) - Should be false` + ); + t.false( + intersectByAreaPercentage(poly1, poly1, -0.1), + `${name}: poly1 vs poly1 (invalid threshold < 0.0) - Should be false` + ); + t.true( + intersectByAreaPercentage(poly1, multiPoly1, 0.49), + `${name}: poly1 vs multiPoly1 (threshold 0.49) - Should intersect >= 49%` + ); + t.false( + intersectByAreaPercentage(poly1, multiPoly1, 0.51), + `${name}: poly1 vs multiPoly1 (threshold 0.51) - Should intersect < 51%` + ); + } + + // Additional edge cases + const zeroAreaPoly = polygon([[[0, 0], [0, 0], [0, 0], [0, 0]]]); + const targetPoly = polygon([[[0, 0], [10, 0], [10, 10], [0, 10], [0, 0]]]); + + t.true(intersectByAreaPercentage(targetPoly, zeroAreaPoly, 0.0), "Zero area test polygon with threshold 0.0"); + t.false(intersectByAreaPercentage(targetPoly, zeroAreaPoly, 0.1), "Zero area test polygon with threshold > 0.0"); + t.false(intersectByAreaPercentage(null, targetPoly, 0.5), "Null target polygon"); + t.false(intersectByAreaPercentage(targetPoly, null, 0.5), "Null test polygon"); + // @ts-expect-error testing invalid input + t.false(intersectByAreaPercentage(targetPoly, targetPoly, 'invalid'), "Invalid threshold type"); + + t.end(); +}); \ No newline at end of file diff --git a/packages/turf-intersect-by-area-percentage/test/in/polygons.json b/packages/turf-intersect-by-area-percentage/test/in/polygons.json new file mode 100644 index 000000000..a46a5a050 --- /dev/null +++ b/packages/turf-intersect-by-area-percentage/test/in/polygons.json @@ -0,0 +1,48 @@ +{ + "type": "FeatureCollection", + "features": [ + { + "type": "Feature", + "properties": { "id": "poly1" }, + "geometry": { + "type": "Polygon", + "coordinates": [[[-10, -10], [10, -10], [10, 10], [-10, 10], [-10, -10]]] + } + }, + { + "type": "Feature", + "properties": { "id": "poly2" }, + "geometry": { + "type": "Polygon", + "coordinates": [[[0, 0], [20, 0], [20, 20], [0, 20], [0, 0]]] + } + }, + { + "type": "Feature", + "properties": { "id": "poly3" }, + "geometry": { + "type": "Polygon", + "coordinates": [[[-5, -5], [5, -5], [5, 5], [-5, 5], [-5, -5]]] + } + }, + { + "type": "Feature", + "properties": { "id": "polyNoOverlap" }, + "geometry": { + "type": "Polygon", + "coordinates": [[[50, 50], [60, 50], [60, 60], [50, 60], [50, 50]]] + } + }, + { + "type": "Feature", + "properties": { "id": "multiPoly1" }, + "geometry": { + "type": "MultiPolygon", + "coordinates": [ + [[[5, 5], [15, 5], [15, 15], [5, 15], [5, 5]]], + [[[-15, -15], [-5, -15], [-5, -5], [-15, -5], [-15, -15]]] + ] + } + } + ] +} \ No newline at end of file diff --git a/packages/turf-intersect-by-area-percentage/tsconfig.json b/packages/turf-intersect-by-area-percentage/tsconfig.json new file mode 100644 index 000000000..0ddb8c84f --- /dev/null +++ b/packages/turf-intersect-by-area-percentage/tsconfig.json @@ -0,0 +1,10 @@ +{ + "extends": "../../tsconfig.shared.json", + "compilerOptions": { + "outDir": "./dist/esm", + "rootDir": ".", + "composite": true + }, + "include": ["*.ts", "*.d.ts", "./lib"], + "exclude": ["test.ts", "bench.ts", "./dist"] +} \ No newline at end of file diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b9674968f..164daa1ae 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -3443,6 +3443,58 @@ importers: specifier: ^5.0.0 version: 5.0.0 + packages/turf-intersect-by-area-percentage: + dependencies: + '@turf/area': + specifier: workspace:^ + version: link:../turf-area + '@turf/helpers': + specifier: workspace:^ + version: link:../turf-helpers + '@turf/intersect': + specifier: workspace:^ + version: link:../turf-intersect + '@turf/invariant': + specifier: workspace:^ + version: link:../turf-invariant + '@types/geojson': + specifier: ^7946.0.10 + version: 7946.0.14 + tslib: + specifier: ^2.8.1 + version: 2.8.1 + devDependencies: + '@types/benchmark': + specifier: ^2.1.5 + version: 2.1.5 + '@types/tape': + specifier: ^4.13.4 + version: 4.13.4 + benchmark: + specifier: ^2.1.4 + version: 2.1.4 + load-json-file: + specifier: ^7.0.1 + version: 7.0.1 + npm-run-all: + specifier: ^4.1.5 + version: 4.1.5 + tape: + specifier: ^5.9.0 + version: 5.9.0 + tsup: + specifier: ^8.3.5 + version: 8.3.5(postcss@8.4.49)(tsx@4.19.2)(typescript@5.5.4) + tsx: + specifier: ^4.19.2 + version: 4.19.2 + typescript: + specifier: ^5.5.4 + version: 5.5.4 + write-json-file: + specifier: ^5.0.0 + version: 5.0.0 + packages/turf-invariant: dependencies: '@turf/helpers':