Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Normalization functions for mask/viewports #680

Merged
Merged
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
stefano-xy marked this conversation as resolved.
Show resolved Hide resolved

- Fix type propTypes issues [#677](https://github.com/CartoDB/carto-react/pull/677)

Expand Down
3 changes: 3 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
189 changes: 187 additions & 2 deletions packages/react-core/__tests__/utils/geo.test.js
Original file line number Diff line number Diff line change
@@ -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
Expand Down Expand Up @@ -37,7 +42,6 @@ describe('isGlobalViewport', () => {
});

test.each(globalViewports)('return true for global viewports', ({ v }) => {
console.log(viewport);
expect(isGlobalViewport(v));
});
});
Expand All @@ -61,3 +65,184 @@ 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);
});

test('it supports null', () => {
expect(normalizeGeometry(null)).toStrictEqual(null);
});
});
3 changes: 3 additions & 0 deletions packages/react-core/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
Expand Down
2 changes: 1 addition & 1 deletion packages/react-core/src/index.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down
2 changes: 1 addition & 1 deletion packages/react-core/src/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand Down
2 changes: 2 additions & 0 deletions packages/react-core/src/utils/geo.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 | null): Polygon | MultiPolygon | null
99 changes: 95 additions & 4 deletions packages/react-core/src/utils/geo.js
Original file line number Diff line number Diff line change
@@ -1,12 +1,16 @@
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';
Comment on lines +3 to +5
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ok, it sounds like we should add those as peerDependencies in react-core, right?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Don't we have them already?


/**
* Select the geometry to use for widget calculation and data filtering.
* If a spatial filter (mask) is set, use the mask otherwise use the current viewport.
* 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
Expand All @@ -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) {
Expand All @@ -35,3 +38,91 @@ 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) {
let result = null;

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);

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;
}
Loading