From 6b86f839714ea7be9f897c3ca86bb2788e8fd27f Mon Sep 17 00:00:00 2001 From: Stefano Pettini Date: Mon, 22 May 2023 17:40:00 +0200 Subject: [PATCH 1/4] Normalization functions for mask/viewports --- .../react-core/__tests__/utils/geo.test.js | 185 +++++++++++++++++- packages/react-core/src/utils/geo.d.ts | 2 + packages/react-core/src/utils/geo.js | 96 ++++++++- 3 files changed, 277 insertions(+), 6 deletions(-) diff --git a/packages/react-core/__tests__/utils/geo.test.js b/packages/react-core/__tests__/utils/geo.test.js index 3cbc0d57b..f82b0c73a 100644 --- a/packages/react-core/__tests__/utils/geo.test.js +++ b/packages/react-core/__tests__/utils/geo.test.js @@ -1,5 +1,10 @@ import bboxPolygon from '@turf/bbox-polygon'; -import { isGlobalViewport, getGeometryToIntersect } from '../../src/utils/geo'; +import { + isGlobalViewport, + getGeometryToIntersect, + normalizeGeometry +} from '../../src/utils/geo'; +import { polygon, multiPolygon } from '@turf/helpers'; /** @type { import('../../src').Viewport } */ const viewport = [-10, -10, 10, 10]; // west - south - east - north @@ -37,7 +42,6 @@ describe('isGlobalViewport', () => { }); test.each(globalViewports)('return true for global viewports', ({ v }) => { - console.log(viewport); expect(isGlobalViewport(v)); }); }); @@ -61,3 +65,180 @@ describe('getGeometryToIntersect', () => { ); }); }); + +describe('normalizeGeometry', () => { + test('does not clip when not needed', () => { + const input = polygon([ + [ + [-90, 0], + [0, -45], + [90, 0], + [0, 45], + [-90, 0] + ] + ]).geometry; + const expected = input; + expect(normalizeGeometry(input)).toStrictEqual(expected); + }); + + test('it produces multipolygons wrapping from the west', () => { + const input = multiPolygon([ + [ + [ + [-90, 0], + [0, -45], + [90, 0], + [0, 45], + [-90, 0] + ] + ], + [ + [ + [-190, -50], + [-170, -70], + [-170, 70], + [-190, 50], + [-190, -50] + ] + ] + ]).geometry; + const expected = multiPolygon([ + [ + [ + [-180, -60], + [-170, -70], + [-170, 70], + [-180, 60], + [-180, -60] + ] + ], + [ + [ + [-90, 0], + [0, -45], + [90, 0], + [0, 45], + [-90, 0] + ] + ], + [ + [ + [170, -50], + [180, -60], + [180, 60], + [170, 50], + [170, -50] + ] + ] + ]).geometry; + expect(normalizeGeometry(input)).toStrictEqual(expected); + }); + + test('it produces multipolygons wrapping from the east', () => { + const input = multiPolygon([ + [ + [ + [-90, 0], + [0, -45], + [90, 0], + [0, 45], + [-90, 0] + ] + ], + [ + [ + [170, -50], + [190, -70], + [190, 70], + [170, 50], + [170, -50] + ] + ] + ]).geometry; + const expected = multiPolygon([ + [ + [ + [-180, -60], + [-170, -70], + [-170, 70], + [-180, 60], + [-180, -60] + ] + ], + [ + [ + [-90, 0], + [0, -45], + [90, 0], + [0, 45], + [-90, 0] + ] + ], + [ + [ + [170, -50], + [180, -60], + [180, 60], + [170, 50], + [170, -50] + ] + ] + ]).geometry; + expect(normalizeGeometry(input)).toStrictEqual(expected); + }); + + test('it unwraps large viewports', () => { + const input = polygon([ + [ + [-200, -80], + [210, -80], + [210, 75], + [-200, 75], + [-200, -80] + ] + ]).geometry; + const expected = polygon([ + [ + [-180, -80], + [180, -80], + [180, 75], + [-180, 75], + [-180, -80] + ] + ]).geometry; + expect(normalizeGeometry(input)).toStrictEqual(expected); + }); + + test('it absorbes unneeded polygons', () => { + const input = multiPolygon([ + [ + [ + [-200, -80], + [210, -80], + [210, 75], + [-200, 75], + [-200, -80] + ] + ], + [ + [ + [-90, 0], + [0, -45], + [90, 0], + [0, 45], + [-90, 0] + ] + ] + ]).geometry; + const expected = polygon([ + [ + [-180, -80], + [180, -80], + [180, 75], + [-180, 75], + [-180, -80] + ] + ]).geometry; + expect(normalizeGeometry(input)).toStrictEqual(expected); + }); +}); diff --git a/packages/react-core/src/utils/geo.d.ts b/packages/react-core/src/utils/geo.d.ts index fef625c1c..b0388f739 100644 --- a/packages/react-core/src/utils/geo.d.ts +++ b/packages/react-core/src/utils/geo.d.ts @@ -4,3 +4,5 @@ import { Polygon, MultiPolygon } from 'geojson'; export function getGeometryToIntersect(viewport: Viewport | null, geometry: Polygon | MultiPolygon | null): Polygon | MultiPolygon | null; export function isGlobalViewport(viewport: Viewport | null): boolean; + +export function normalizeGeometry(geometry: Polygon | MultiPolygon): Polygon | MultiPolygon | null \ No newline at end of file diff --git a/packages/react-core/src/utils/geo.js b/packages/react-core/src/utils/geo.js index 822ad4b79..9226d3e19 100644 --- a/packages/react-core/src/utils/geo.js +++ b/packages/react-core/src/utils/geo.js @@ -1,4 +1,8 @@ +import bboxClip from '@turf/bbox-clip'; import bboxPolygon from '@turf/bbox-polygon'; +import union from '@turf/union'; +import { getType } from '@turf/invariant'; +import { polygon, multiPolygon } from '@turf/helpers'; /** * Select the geometry to use for widget calculation and data filtering. @@ -6,7 +10,7 @@ import bboxPolygon from '@turf/bbox-polygon'; * Since it's possible that no mask and no viewport is set, return null in this case. * * @typedef { import('geojson').Polygon | import('geojson').MultiPolygon } Geometry - * @typedef { import('../types').Viewport? } Viewport + * @typedef { import('../types').Viewport } Viewport * * @param { Viewport? } viewport viewport [minX, minY, maxX, maxY], if any * @param { Geometry? } geometry the active spatial filter (mask), if any @@ -22,10 +26,9 @@ export function getGeometryToIntersect(viewport, geometry) { /** * Check if a viewport is large enough to represent a global coverage. - * In this case the spatial filter parameter for widget calculation - * can be removed. + * In this case the spatial filter parameter for widget calculation is removed. * - * @param { import('../types').Viewport? } viewport + * @param { Viewport? } viewport * @returns { boolean } */ export function isGlobalViewport(viewport) { @@ -35,3 +38,88 @@ export function isGlobalViewport(viewport) { } return false; } + +function cleanPolygonCoords(cc) { + const coords = cc.filter((c) => c.length > 0); + return coords.length > 0 ? coords : null; +} + +function cleanMultiPolygonCoords(ccc) { + const coords = ccc.map(cleanPolygonCoords).filter((cc) => cc); + return coords.length > 0 ? coords : null; +} + +function clean(geometry) { + if (!geometry) { + return null; + } else if (getType(geometry) === 'Polygon') { + const coords = cleanPolygonCoords(geometry.coordinates); + return coords ? polygon(coords).geometry : null; + } else if (getType(geometry) === 'MultiPolygon') { + const coords = cleanMultiPolygonCoords(geometry.coordinates); + return coords ? multiPolygon(coords).geometry : null; + } else { + return null; + } +} + +function txContourCoords(cc, distance) { + return cc.map((c) => [c[0] + distance, c[1]]); +} + +function txPolygonCoords(ccc, distance) { + return ccc.map((cc) => txContourCoords(cc, distance)); +} + +function txMultiPolygonCoords(cccc, distance) { + return cccc.map((ccc) => txPolygonCoords(ccc, distance)); +} + +function tx(geometry, distance) { + if (geometry && getType(geometry) === 'Polygon') { + const coords = txPolygonCoords(geometry.coordinates, distance); + return polygon(coords).geometry; + } else if (geometry && getType(geometry) === 'MultiPolygon') { + const coords = txMultiPolygonCoords(geometry.coordinates, distance); + return multiPolygon(coords).geometry; + } else { + return null; + } +} + +/** + * Normalized a geometry, coming from a mask or a viewport. The parts + * spanning outside longitude range [-180, +180] are clipped and "folded" + * back to the valid range and unioned to the polygons inide that range. + * + * It results in a Polygon or MultiPolygon strictly inside the validity range. + * + * @param {Geometry} geometry + * @returns {Geometry?} + */ +export function normalizeGeometry(geometry) { + const WORLD = [-180, -90, +180, +90]; + + const worldClip = clean(bboxClip(geometry, WORLD).geometry); + + const geometryTxWest = tx(geometry, 360); + const geometryTxEast = tx(geometry, -360); + + let result = worldClip; + + if (result && geometryTxWest) { + const worldWestClip = clean(bboxClip(geometryTxWest, WORLD).geometry); + if (worldWestClip) { + result = clean(union(result, worldWestClip)?.geometry); + } + } + + if (result && geometryTxEast) { + const worldEastClip = clean(bboxClip(geometryTxEast, WORLD).geometry); + if (worldEastClip) { + result = clean(union(result, worldEastClip)?.geometry); + } + } + + return result; +} From dda7b252e903222089d8e54a0cb3dd6f1b8226b3 Mon Sep 17 00:00:00 2001 From: Stefano Pettini Date: Mon, 22 May 2023 19:33:08 +0200 Subject: [PATCH 2/4] Support for null --- .../react-core/__tests__/utils/geo.test.js | 4 +++ packages/react-core/src/utils/geo.d.ts | 2 +- packages/react-core/src/utils/geo.js | 33 ++++++++++--------- 3 files changed, 23 insertions(+), 16 deletions(-) diff --git a/packages/react-core/__tests__/utils/geo.test.js b/packages/react-core/__tests__/utils/geo.test.js index f82b0c73a..87b88ed77 100644 --- a/packages/react-core/__tests__/utils/geo.test.js +++ b/packages/react-core/__tests__/utils/geo.test.js @@ -241,4 +241,8 @@ describe('normalizeGeometry', () => { ]).geometry; expect(normalizeGeometry(input)).toStrictEqual(expected); }); + + test('it supports null', () => { + expect(normalizeGeometry(null)).toStrictEqual(null); + }); }); diff --git a/packages/react-core/src/utils/geo.d.ts b/packages/react-core/src/utils/geo.d.ts index b0388f739..65f5b207a 100644 --- a/packages/react-core/src/utils/geo.d.ts +++ b/packages/react-core/src/utils/geo.d.ts @@ -5,4 +5,4 @@ export function getGeometryToIntersect(viewport: Viewport | null, geometry: Poly export function isGlobalViewport(viewport: Viewport | null): boolean; -export function normalizeGeometry(geometry: Polygon | MultiPolygon): Polygon | MultiPolygon | null \ No newline at end of file +export function normalizeGeometry(geometry: Polygon | MultiPolygon | null): Polygon | MultiPolygon | null \ No newline at end of file diff --git a/packages/react-core/src/utils/geo.js b/packages/react-core/src/utils/geo.js index 9226d3e19..b72a871d9 100644 --- a/packages/react-core/src/utils/geo.js +++ b/packages/react-core/src/utils/geo.js @@ -94,30 +94,33 @@ function tx(geometry, distance) { * * It results in a Polygon or MultiPolygon strictly inside the validity range. * - * @param {Geometry} geometry + * @param {Geometry?} geometry * @returns {Geometry?} */ export function normalizeGeometry(geometry) { - const WORLD = [-180, -90, +180, +90]; + let result = null; - const worldClip = clean(bboxClip(geometry, WORLD).geometry); + if (geometry) { + const WORLD = [-180, -90, +180, +90]; + const worldClip = clean(bboxClip(geometry, WORLD).geometry); - const geometryTxWest = tx(geometry, 360); - const geometryTxEast = tx(geometry, -360); + const geometryTxWest = tx(geometry, 360); + const geometryTxEast = tx(geometry, -360); - let result = worldClip; + result = worldClip; - if (result && geometryTxWest) { - const worldWestClip = clean(bboxClip(geometryTxWest, WORLD).geometry); - if (worldWestClip) { - result = clean(union(result, worldWestClip)?.geometry); + if (result && geometryTxWest) { + const worldWestClip = clean(bboxClip(geometryTxWest, WORLD).geometry); + if (worldWestClip) { + result = clean(union(result, worldWestClip)?.geometry); + } } - } - if (result && geometryTxEast) { - const worldEastClip = clean(bboxClip(geometryTxEast, WORLD).geometry); - if (worldEastClip) { - result = clean(union(result, worldEastClip)?.geometry); + if (result && geometryTxEast) { + const worldEastClip = clean(bboxClip(geometryTxEast, WORLD).geometry); + if (worldEastClip) { + result = clean(union(result, worldEastClip)?.geometry); + } } } From a1f419aefbb6fca3837f26b8a813746ad2c4455d Mon Sep 17 00:00:00 2001 From: Stefano Pettini Date: Mon, 22 May 2023 19:36:23 +0200 Subject: [PATCH 3/4] Fixed --- packages/react-core/src/index.d.ts | 2 +- packages/react-core/src/index.js | 2 +- packages/react-widgets/src/hooks/useWidgetFetch.js | 7 +++++-- 3 files changed, 7 insertions(+), 4 deletions(-) diff --git a/packages/react-core/src/index.d.ts b/packages/react-core/src/index.d.ts index be9db20bc..4114556d3 100644 --- a/packages/react-core/src/index.d.ts +++ b/packages/react-core/src/index.d.ts @@ -15,7 +15,7 @@ export { debounce } from './utils/debounce'; export { throttle } from './utils/throttle'; export { randomString } from './utils/randomString'; export { assert as _assert } from './utils/assert'; -export { getGeometryToIntersect, isGlobalViewport } from './utils/geo'; +export { getGeometryToIntersect, isGlobalViewport, normalizeGeometry } from './utils/geo'; export { makeIntervalComplete } from './utils/makeIntervalComplete'; diff --git a/packages/react-core/src/index.js b/packages/react-core/src/index.js index 64acf71b4..c5d9414c8 100644 --- a/packages/react-core/src/index.js +++ b/packages/react-core/src/index.js @@ -13,7 +13,7 @@ export { debounce } from './utils/debounce'; export { throttle } from './utils/throttle'; export { randomString } from './utils/randomString'; export { assert as _assert } from './utils/assert'; -export { getGeometryToIntersect, isGlobalViewport } from './utils/geo'; +export { getGeometryToIntersect, isGlobalViewport, normalizeGeometry } from './utils/geo'; export { makeIntervalComplete } from './utils/makeIntervalComplete'; diff --git a/packages/react-widgets/src/hooks/useWidgetFetch.js b/packages/react-widgets/src/hooks/useWidgetFetch.js index 625552021..acf5f4ead 100644 --- a/packages/react-widgets/src/hooks/useWidgetFetch.js +++ b/packages/react-widgets/src/hooks/useWidgetFetch.js @@ -1,7 +1,8 @@ import { InvalidColumnError, getGeometryToIntersect, - isGlobalViewport + isGlobalViewport, + normalizeGeometry } from '@carto/react-core'; import { selectAreFeaturesReadyForSource, @@ -47,7 +48,9 @@ export default function useWidgetFetch( const geometryToIntersect = global || (!spatialFilter && isGlobalViewport(viewport)) ? null - : getGeometryToIntersect(viewport, spatialFilter ? spatialFilter.geometry : null); + : normalizeGeometry( + getGeometryToIntersect(viewport, spatialFilter ? spatialFilter.geometry : null) + ); useCustomCompareEffect( () => { From 23d76b993a1e1ab38a541b7af2de8ace9ef95ef1 Mon Sep 17 00:00:00 2001 From: Stefano Pettini Date: Mon, 22 May 2023 19:50:45 +0200 Subject: [PATCH 4/4] Packages and changelog --- CHANGELOG.md | 4 +++- package.json | 3 +++ packages/react-core/package.json | 3 +++ 3 files changed, 9 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 6980ab422..6fdb2876d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,9 +2,11 @@ ## Not released +- Fix widget calculation with very large viewports/masks [#680](https://github.com/CartoDB/carto-react/pull/680) + ## 2.0 -## 2.0.4 (2023-05-19) +### 2.0.4 (2023-05-19) - Fix type propTypes issues [#677](https://github.com/CartoDB/carto-react/pull/677) diff --git a/package.json b/package.json index 1ecd02915..3b18454c9 100644 --- a/package.json +++ b/package.json @@ -30,7 +30,10 @@ "@turf/bbox-polygon": "^6.3.0", "@turf/boolean-intersects": "^6.3.0", "@turf/boolean-within": "^6.3.0", + "@turf/helpers": "^6.3.0", "@turf/intersect": "^6.3.0", + "@turf/invariant": "^6.3.0", + "@turf/union": "^6.3.0", "dequal": "^2.0.2", "echarts": "^5.4.2", "echarts-for-react": "^3.0.2", diff --git a/packages/react-core/package.json b/packages/react-core/package.json index b0bb0df07..5a5d0ae74 100644 --- a/packages/react-core/package.json +++ b/packages/react-core/package.json @@ -69,7 +69,10 @@ "@turf/bbox-polygon": "^6.3.0", "@turf/boolean-intersects": "^6.3.0", "@turf/boolean-within": "^6.3.0", + "@turf/helpers": "^6.3.0", "@turf/intersect": "^6.3.0", + "@turf/invariant": "^6.3.0", + "@turf/union": "^6.3.0", "h3-js": "^3.7.2", "quadbin": "^0.1.9" }