Skip to content

Commit

Permalink
feat: Add createViewportSpatialFilter()
Browse files Browse the repository at this point in the history
  • Loading branch information
donmccurdy authored Sep 25, 2024
2 parents 1e5f07b + 4242ae0 commit 1f64d48
Show file tree
Hide file tree
Showing 7 changed files with 555 additions and 15 deletions.
19 changes: 6 additions & 13 deletions examples/components/widgets/base-widget.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
import {css, CSSResultGroup, LitElement} from 'lit';
import {SpatialFilter, WidgetSource} from '@carto/api-client';
import {
createViewportSpatialFilter,
SpatialFilter,
WidgetSource,
} from '@carto/api-client';
import {MapViewState, WebMercatorViewport} from '@deck.gl/core';

export abstract class BaseWidget extends LitElement {
Expand Down Expand Up @@ -71,18 +75,7 @@ export abstract class BaseWidget extends LitElement {

if (this.viewState) {
const viewport = new WebMercatorViewport(this.viewState);
return {
type: 'Polygon',
coordinates: [
[
viewport.unproject([0, 0]),
viewport.unproject([viewport.width, 0]),
viewport.unproject([viewport.width, viewport.height]),
viewport.unproject([0, viewport.height]),
viewport.unproject([0, 0]),
],
],
};
return createViewportSpatialFilter(viewport.getBounds());
}

return undefined;
Expand Down
8 changes: 8 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -89,5 +89,13 @@
"vite": "^5.2.10",
"vitest": "1.6.0",
"vue": "^3.4.27"
},
"dependencies": {
"@turf/bbox-clip": "^7.1.0",
"@turf/bbox-polygon": "^7.1.0",
"@turf/helpers": "^7.1.0",
"@turf/invariant": "^7.1.0",
"@turf/union": "^7.1.0",
"@types/geojson": "^7946.0.14"
}
}
178 changes: 178 additions & 0 deletions src/geo.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,178 @@
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, feature, featureCollection} from '@turf/helpers';
import type {BBox, Geometry, MultiPolygon, Polygon, Position} from 'geojson';
import {SpatialFilter} from './types';

/**
* Returns a {@link SpatialFilter} for a given viewport, typically obtained
* from deck.gl's `viewport.getBounds()` method ([west, south, east, north]).
* If the viewport covers the entire world (to some margin of error in Web
* Mercator space), `undefined` is returned instead.
*
* If the viewport extends beyond longitude range [-180, +180], the polygon
* may be reformatted for compatibility with CARTO APIs.
*/
export function createViewportSpatialFilter(
viewport: BBox
): SpatialFilter | undefined {
if (_isGlobalViewport(viewport)) {
return;
}
return createPolygonSpatialFilter(bboxPolygon(viewport).geometry);
}

/**
* Returns a {@link SpatialFilter} for a given {@link Polygon} or
* {@link MultiPolygon}. If the polygon(s) extend outside longitude
* range [-180, +180], the result may be reformatted for compatibility
* with CARTO APIs.
*/
export function createPolygonSpatialFilter(
spatialFilter: Polygon | MultiPolygon
): SpatialFilter | undefined {
return (spatialFilter && _normalizeGeometry(spatialFilter)) || undefined;
}

/**
* Check if a viewport is large enough to represent a global coverage.
* In this case the spatial filter parameter for widget calculation is removed.
*
* @internalRemarks Source: @carto/react-core
*/
function _isGlobalViewport(viewport: BBox) {
const [minx, miny, maxx, maxy] = viewport;
return maxx - minx > 179.5 * 2 && maxy - miny > 85.05 * 2;
}

/**
* 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.
*
* @internalRemarks Source: @carto/react-core
*/
function _normalizeGeometry(
geometry: Polygon | MultiPolygon
): Polygon | MultiPolygon | null {
const WORLD = [-180, -90, +180, +90] as BBox;
const worldClip = _clean(
bboxClip(geometry, WORLD).geometry as Polygon | MultiPolygon
);

const geometryTxWest = _tx(geometry, 360);
const geometryTxEast = _tx(geometry, -360);

let result: Polygon | MultiPolygon | null = worldClip;

if (result && geometryTxWest) {
const worldWestClip = _clean(
bboxClip(geometryTxWest, WORLD).geometry as Polygon | MultiPolygon
);
if (worldWestClip) {
const collection = featureCollection([
feature(result),
feature(worldWestClip),
]);
const merged = union(collection);
result = merged ? _clean(merged.geometry) : result;
}
}

if (result && geometryTxEast) {
const worldEastClip = _clean(
bboxClip(geometryTxEast, WORLD).geometry as Polygon | MultiPolygon
);
if (worldEastClip) {
const collection = featureCollection([
feature(result),
feature(worldEastClip),
]);
const merged = union(collection);
result = merged ? _clean(merged.geometry) : result;
}
}

return result;
}

/** @internalRemarks Source: @carto/react-core */
function _cleanPolygonCoords(cc: Position[][]) {
const coords = cc.filter((c) => c.length > 0);
return coords.length > 0 ? coords : null;
}

/** @internalRemarks Source: @carto/react-core */
function _cleanMultiPolygonCoords(ccc: Position[][][]) {
const coords = ccc.map(_cleanPolygonCoords).filter((cc) => cc);
return coords.length > 0 ? coords : null;
}

/** @internalRemarks Source: @carto/react-core */
function _clean(
geometry: Polygon | MultiPolygon | null
): Polygon | MultiPolygon | null {
if (!geometry) {
return null;
}

if (_isPolygon(geometry)) {
const coords = _cleanPolygonCoords((geometry as Polygon).coordinates);
return coords ? polygon(coords).geometry : null;
}

if (_isMultiPolygon(geometry)) {
const coords = _cleanMultiPolygonCoords(
(geometry as MultiPolygon).coordinates
);
return coords ? multiPolygon(coords as Position[][][]).geometry : null;
}

return null;
}

/** @internalRemarks Source: @carto/react-core */
function _txContourCoords(cc: Position[], distance: number) {
return cc.map((c) => [c[0] + distance, c[1]]);
}

/** @internalRemarks Source: @carto/react-core */
function _txPolygonCoords(ccc: Position[][], distance: number) {
return ccc.map((cc) => _txContourCoords(cc, distance));
}

/** @internalRemarks Source: @carto/react-core */
function _txMultiPolygonCoords(cccc: Position[][][], distance: number) {
return cccc.map((ccc) => _txPolygonCoords(ccc, distance));
}

/** @internalRemarks Source: @carto/react-core */
function _tx(geometry: Polygon | MultiPolygon, distance: number) {
if (geometry && getType(geometry) === 'Polygon') {
const coords = _txPolygonCoords(
(geometry as Polygon).coordinates,
distance
);
return polygon(coords).geometry;
} else if (geometry && getType(geometry) === 'MultiPolygon') {
const coords = _txMultiPolygonCoords(
(geometry as MultiPolygon).coordinates,
distance
);
return multiPolygon(coords).geometry;
} else {
return null;
}
}

function _isPolygon(geometry: Geometry): geometry is Polygon {
return getType(geometry) === 'Polygon';
}

function _isMultiPolygon(geometry: Geometry): geometry is MultiPolygon {
return getType(geometry) === 'MultiPolygon';
}
1 change: 1 addition & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
export * from './client.js';
export * from './constants.js';
export * from './filters.js';
export * from './geo.js';
export * from './sources/index.js';
export * from './types.js';
3 changes: 2 additions & 1 deletion src/types.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import type {FilterType} from './constants.js';
import type {Polygon, MultiPolygon} from 'geojson';

/******************************************************************************
* AGGREGATION
Expand All @@ -23,7 +24,7 @@ export type AggregationType =
*/

/** @internalRemarks Source: @carto/react-api */
export type SpatialFilter = GeoJSON.Polygon | GeoJSON.MultiPolygon;
export type SpatialFilter = Polygon | MultiPolygon;

/** @internalRemarks Source: @carto/react-api, @deck.gl/carto */
export interface Filter {
Expand Down
Loading

0 comments on commit 1f64d48

Please sign in to comment.