From 2786f698952f089d36875214f50426d95f4e419f Mon Sep 17 00:00:00 2001 From: James Beard Date: Mon, 8 Jul 2024 17:45:26 +1000 Subject: [PATCH] Converted turf-mask to Typescript and added support for geometry parameters (#2644) * Converted turf-mask to Typescript. Fixed an apparently unreported bug where the typings suggested it was ok to pass a Polygon or MultiPolygon Geometry (rather than a complete Feature), but the code would bomb out. --- packages/turf-mask/README.md | 23 +++---- packages/turf-mask/bench.ts | 11 +-- packages/turf-mask/index.d.ts | 12 ---- packages/turf-mask/index.js | 80 ---------------------- packages/turf-mask/index.ts | 118 ++++++++++++++++++++++++++++++++ packages/turf-mask/package.json | 5 +- packages/turf-mask/test.ts | 78 ++++++++++++++++++++- pnpm-lock.yaml | 9 +++ 8 files changed, 225 insertions(+), 111 deletions(-) delete mode 100644 packages/turf-mask/index.d.ts delete mode 100644 packages/turf-mask/index.js create mode 100644 packages/turf-mask/index.ts diff --git a/packages/turf-mask/README.md b/packages/turf-mask/README.md index cfcfcbb16a..38aca306d6 100644 --- a/packages/turf-mask/README.md +++ b/packages/turf-mask/README.md @@ -4,36 +4,35 @@ ## mask -Takes any type of [polygon][1] and an optional mask and returns a [polygon][1] exterior ring with holes. +Takes polygons or multipolygons and an optional mask, and returns an exterior +ring polygon with holes. ### Parameters -* `polygon` **([FeatureCollection][2] | [Feature][3]<([Polygon][4] | [MultiPolygon][5])>)** GeoJSON Polygon used as interior rings or holes. -* `mask` **[Feature][3]<[Polygon][4]>?** GeoJSON Polygon used as the exterior ring (if undefined, the world extent is used) +* `polygon` **([Polygon][1] | [MultiPolygon][2] | [Feature][3]<([Polygon][1] | [MultiPolygon][2])> | [FeatureCollection][4]<([Polygon][1] | [MultiPolygon][2])>)** GeoJSON polygon used as interior rings or holes +* `mask` **([Polygon][1] | [Feature][3]<[Polygon][1]>)?** GeoJSON polygon used as the exterior ring (if undefined, the world extent is used) ### Examples ```javascript -var polygon = turf.polygon([[[112, -21], [116, -36], [146, -39], [153, -24], [133, -10], [112, -21]]]); -var mask = turf.polygon([[[90, -55], [170, -55], [170, 10], [90, 10], [90, -55]]]); +const polygon = turf.polygon([[[112, -21], [116, -36], [146, -39], [153, -24], [133, -10], [112, -21]]]); +const mask = turf.polygon([[[90, -55], [170, -55], [170, 10], [90, 10], [90, -55]]]); -var masked = turf.mask(polygon, mask); +const masked = turf.mask(polygon, mask); //addToMap -var addToMap = [masked] +const addToMap = [masked] ``` -Returns **[Feature][3]<[Polygon][4]>** Masked Polygon (exterior ring with holes). +Returns **[Feature][3]<[Polygon][1]>** Masked Polygon (exterior ring with holes). [1]: https://tools.ietf.org/html/rfc7946#section-3.1.6 -[2]: https://tools.ietf.org/html/rfc7946#section-3.3 +[2]: https://tools.ietf.org/html/rfc7946#section-3.1.7 [3]: https://tools.ietf.org/html/rfc7946#section-3.2 -[4]: https://tools.ietf.org/html/rfc7946#section-3.1.6 - -[5]: https://tools.ietf.org/html/rfc7946#section-3.1.7 +[4]: https://tools.ietf.org/html/rfc7946#section-3.3 diff --git a/packages/turf-mask/bench.ts b/packages/turf-mask/bench.ts index 7d56ab92a2..a6f1aec249 100644 --- a/packages/turf-mask/bench.ts +++ b/packages/turf-mask/bench.ts @@ -1,8 +1,9 @@ +import { Feature, FeatureCollection, Polygon, MultiPolygon } from "geojson"; import fs from "fs"; import path from "path"; import { fileURLToPath } from "url"; import { loadJsonFileSync } from "load-json-file"; -import Benchmark from "benchmark"; +import Benchmark, { Event } from "benchmark"; import { mask as turfMask } from "./index.js"; const __dirname = path.dirname(fileURLToPath(import.meta.url)); @@ -17,13 +18,15 @@ const directories = { let fixtures = fs.readdirSync(directories.in).map((filename) => { return { name: path.parse(filename).name, - geojson: loadJsonFileSync(path.join(directories.in, filename)), + geojson: loadJsonFileSync( + path.join(directories.in, filename) + ) as FeatureCollection, }; }); for (const { name, geojson } of fixtures) { const [polygon, masking] = geojson.features; - suite.add(name, () => turfMask(polygon, masking)); + suite.add(name, () => turfMask(polygon, masking as Feature)); } // basic x 4,627 ops/sec ±25.23% (21 runs sampled) @@ -31,7 +34,7 @@ for (const { name, geojson } of fixtures) { // multi-polygon x 5,837 ops/sec ±3.03% (91 runs sampled) // overlapping x 22,326 ops/sec ±1.34% (90 runs sampled) suite - .on("cycle", (event) => { + .on("cycle", (event: Event) => { console.log(String(event.target)); }) .run(); diff --git a/packages/turf-mask/index.d.ts b/packages/turf-mask/index.d.ts deleted file mode 100644 index 3efb5d86e2..0000000000 --- a/packages/turf-mask/index.d.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { Feature, Polygon, MultiPolygon, FeatureCollection } from "geojson"; - -/** - * http://turfjs.org/docs/#mask - */ -declare function mask( - poly: Feature | FeatureCollection | T, - mask?: Feature | Polygon -): Feature; - -export { mask }; -export default mask; diff --git a/packages/turf-mask/index.js b/packages/turf-mask/index.js deleted file mode 100644 index 5718c98268..0000000000 --- a/packages/turf-mask/index.js +++ /dev/null @@ -1,80 +0,0 @@ -import { polygon as createPolygon, multiPolygon } from "@turf/helpers"; -import polygonClipping from "polygon-clipping"; - -/** - * Takes any type of {@link Polygon|polygon} and an optional mask and returns a {@link Polygon|polygon} exterior ring with holes. - * - * @name mask - * @param {FeatureCollection|Feature} polygon GeoJSON Polygon used as interior rings or holes. - * @param {Feature} [mask] GeoJSON Polygon used as the exterior ring (if undefined, the world extent is used) - * @returns {Feature} Masked Polygon (exterior ring with holes). - * @example - * var polygon = turf.polygon([[[112, -21], [116, -36], [146, -39], [153, -24], [133, -10], [112, -21]]]); - * var mask = turf.polygon([[[90, -55], [170, -55], [170, 10], [90, 10], [90, -55]]]); - * - * var masked = turf.mask(polygon, mask); - * - * //addToMap - * var addToMap = [masked] - */ -function mask(polygon, mask) { - // Define mask - var maskPolygon = createMask(mask); - - var polygonOuters = null; - if (polygon.type === "FeatureCollection") polygonOuters = unionFc(polygon); - else - polygonOuters = createGeomFromPolygonClippingOutput( - polygonClipping.union(polygon.geometry.coordinates) - ); - - polygonOuters.geometry.coordinates.forEach(function (contour) { - maskPolygon.geometry.coordinates.push(contour[0]); - }); - - return maskPolygon; -} - -function unionFc(fc) { - var unioned = - fc.features.length === 2 - ? polygonClipping.union( - fc.features[0].geometry.coordinates, - fc.features[1].geometry.coordinates - ) - : polygonClipping.union.apply( - polygonClipping, - fc.features.map(function (f) { - return f.geometry.coordinates; - }) - ); - return createGeomFromPolygonClippingOutput(unioned); -} - -function createGeomFromPolygonClippingOutput(unioned) { - return multiPolygon(unioned); -} - -/** - * Create Mask Coordinates - * - * @private - * @param {Feature} [mask] default to world if undefined - * @returns {Feature} mask coordinate - */ -function createMask(mask) { - var world = [ - [ - [180, 90], - [-180, 90], - [-180, -90], - [180, -90], - [180, 90], - ], - ]; - var coordinates = (mask && mask.geometry.coordinates) || world; - return createPolygon(coordinates); -} - -export { mask }; -export default mask; diff --git a/packages/turf-mask/index.ts b/packages/turf-mask/index.ts new file mode 100644 index 0000000000..5d83ce3c94 --- /dev/null +++ b/packages/turf-mask/index.ts @@ -0,0 +1,118 @@ +import { + Feature, + FeatureCollection, + Polygon, + Position, + MultiPolygon, +} from "geojson"; +import { polygon as createPolygon, multiPolygon } from "@turf/helpers"; +import polygonClipping, { Geom } from "polygon-clipping"; + +/** + * Takes polygons or multipolygons and an optional mask, and returns an exterior + * ring polygon with holes. + * + * @name mask + * @param {Polygon|MultiPolygon|Feature|FeatureCollection} polygon GeoJSON polygon used as interior rings or holes + * @param {Polygon|Feature} [mask] GeoJSON polygon used as the exterior ring (if undefined, the world extent is used) + * @returns {Feature} Masked Polygon (exterior ring with holes). + * @example + * const polygon = turf.polygon([[[112, -21], [116, -36], [146, -39], [153, -24], [133, -10], [112, -21]]]); + * const mask = turf.polygon([[[90, -55], [170, -55], [170, 10], [90, 10], [90, -55]]]); + * + * const masked = turf.mask(polygon, mask); + * + * //addToMap + * const addToMap = [masked] + */ +function mask( + polygon: T | Feature | FeatureCollection, + mask?: Polygon | Feature +): Feature { + // Define mask + const maskPolygon = createMask(mask); + + let polygonOuters = null; + if (polygon.type === "FeatureCollection") { + polygonOuters = unionFc(polygon); + } else if (polygon.type === "Feature") { + // Need to cast below as Position[][] isn't quite as strict as Geom, even + // though they should be equivalent. + polygonOuters = createGeomFromPolygonClippingOutput( + polygonClipping.union(polygon.geometry.coordinates as Geom) + ); + } else { + // Geometry + // Need to cast below as Position[][] isn't quite as strict as Geom, even + // though they should be equivalent. + polygonOuters = createGeomFromPolygonClippingOutput( + polygonClipping.union(polygon.coordinates as Geom) + ); + } + + polygonOuters.geometry.coordinates.forEach(function (contour) { + maskPolygon.geometry.coordinates.push(contour[0]); + }); + + return maskPolygon; +} + +function unionFc(fc: FeatureCollection) { + // Need to cast below as Position[][] isn't quite as strict as Geom, even + // though they should be equivalent. + + // Stick with apply() below as spread operator degrades performance. Have + // to disable prefer-spread lint rule though. + /* eslint-disable prefer-spread */ + const unioned = + fc.features.length === 2 + ? polygonClipping.union( + fc.features[0].geometry.coordinates as Geom, + fc.features[1].geometry.coordinates as Geom + ) + : polygonClipping.union.apply( + polygonClipping, + fc.features.map(function (f) { + return f.geometry.coordinates; + }) as [Geom, ...Geom[]] + ); + /* eslint-enable */ + return createGeomFromPolygonClippingOutput(unioned); +} + +function createGeomFromPolygonClippingOutput(unioned: Position[][][]) { + return multiPolygon(unioned); +} + +/** + * Create Mask Coordinates + * + * @private + * @param {Feature} [mask] default to world if undefined + * @returns {Feature} mask as a polygon + */ +function createMask(mask: Feature | Polygon | undefined) { + const world = [ + [ + [180, 90], + [-180, 90], + [-180, -90], + [180, -90], + [180, 90], + ], + ]; + let coordinates = world; + if (mask) { + if (mask.type === "Feature") { + // polygon feature + coordinates = mask.geometry.coordinates; + } else { + // polygon geometry + coordinates = mask.coordinates; + } + } + return createPolygon(coordinates); +} + +export { mask }; +export default mask; diff --git a/packages/turf-mask/package.json b/packages/turf-mask/package.json index 3f3a07f787..f921c11a91 100644 --- a/packages/turf-mask/package.json +++ b/packages/turf-mask/package.json @@ -60,10 +60,13 @@ "tape": "^5.7.2", "tsup": "^8.0.1", "tsx": "^4.6.2", + "typescript": "^5.2.2", "write-json-file": "^5.0.0" }, "dependencies": { "@turf/helpers": "workspace:^", - "polygon-clipping": "^0.15.3" + "@types/geojson": "7946.0.8", + "polygon-clipping": "^0.15.3", + "tslib": "^2.6.2" } } diff --git a/packages/turf-mask/test.ts b/packages/turf-mask/test.ts index a9f893f299..51cf142bd0 100644 --- a/packages/turf-mask/test.ts +++ b/packages/turf-mask/test.ts @@ -1,3 +1,10 @@ +import { + Feature, + FeatureCollection, + Polygon, + Position, + MultiPolygon, +} from "geojson"; import fs from "fs"; import test from "tape"; import path from "path"; @@ -19,7 +26,9 @@ let fixtures = fs.readdirSync(directories.in).map((filename) => { return { filename, name: path.parse(filename).name, - geojson: loadJsonFileSync(path.join(directories.in, filename)), + geojson: loadJsonFileSync( + path.join(directories.in, filename) + ) as FeatureCollection, }; }); @@ -30,7 +39,7 @@ test("turf-mask", (t) => { } const [polygon, masking] = geojson.features; - const results = mask(polygon, masking); + const results = mask(polygon, masking as Feature); if (process.env.REGEN) writeJsonFileSync(directories.out + filename, results); @@ -38,3 +47,68 @@ test("turf-mask", (t) => { } t.end(); }); + +test("turf-mask polygon geometry", (t) => { + // A polygon somewhere + const polyCoords: Position[] = [ + [9, 13], + [68, 13], + [68, 50], + [9, 50], + [9, 13], + ]; + + const polygonGeometry: Polygon = { + type: "Polygon", + coordinates: [polyCoords], + }; + + let expectedResult = { + type: "Feature", + properties: {}, + geometry: { + type: "Polygon", + coordinates: [ + [ + [180, 90], + [-180, 90], + [-180, -90], + [180, -90], + [180, 90], + ], + polyCoords, + ], + }, + }; + + let result = mask(polygonGeometry); + t.deepEquals(result, expectedResult, "default mask"); + + // A slightly larger polygon surrounding the one above + const customMaskCoords: Position[] = [ + [6, 10], + [71, 10], + [71, 53], + [6, 53], + [6, 10], + ]; + + const maskGeometry: Polygon = { + type: "Polygon", + coordinates: [customMaskCoords], + }; + + expectedResult = { + type: "Feature", + properties: {}, + geometry: { + type: "Polygon", + coordinates: [customMaskCoords, polyCoords], + }, + }; + + result = mask(polygonGeometry, maskGeometry); + t.deepEquals(result, expectedResult, "custom mask"); + + t.end(); +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b2a4bd38a2..3849df9af2 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -4033,9 +4033,15 @@ importers: '@turf/helpers': specifier: workspace:^ version: link:../turf-helpers + '@types/geojson': + specifier: 7946.0.8 + version: 7946.0.8 polygon-clipping: specifier: ^0.15.3 version: 0.15.3 + tslib: + specifier: ^2.6.2 + version: 2.6.2 devDependencies: '@types/benchmark': specifier: ^2.1.5 @@ -4064,6 +4070,9 @@ importers: tsx: specifier: ^4.6.2 version: 4.6.2 + typescript: + specifier: ^5.2.2 + version: 5.3.3 write-json-file: specifier: ^5.0.0 version: 5.0.0