@seasketch/geoprocessing
Version:
Geoprocessing and reporting framework for SeaSketch 2.0
308 lines (283 loc) • 10.9 kB
text/typescript
import {
isPolygonFeature,
isPolygonFeatureArray,
numberFormat,
} from "../helpers/index.js";
import { clipMultiMerge } from "./clip.js";
import {
ValidationError,
Feature,
Polygon,
MultiPolygon,
BBox,
} from "../types/index.js";
import {
area,
bbox,
featureCollection as fc,
flatten,
kinks,
booleanValid,
} from "@turf/turf";
import {
isExternalVectorDatasource,
isInternalVectorDatasource,
} from "../datasources/helpers.js";
import { ProjectClientInterface } from "../project/ProjectClientBase.js";
import { getDatasourceFeatures } from "../dataproviders/getDatasourceFeatures.js";
import { clip } from "./clip.js";
/** Supported clip operations */
export type ClipOperations = "intersection" | "difference";
/** Parameters for clip operation using polygon features */
export interface FeatureClipOperation {
clipFeatures: Feature<Polygon | MultiPolygon>[];
operation: ClipOperations;
}
export interface DatasourceOptions {
/** Fetches features overlapping with bounding box */
bbox?: BBox;
/** Filter features by property having one or more specific values */
propertyFilter?: {
property: string;
values: (string | number)[];
};
/** Provide if you have subdivided dataset and want to rebuild (union) subdivided polygons based on having same value for this property name */
unionProperty?: string;
}
/** Parameters for clip operation using a datasource */
export interface DatasourceClipOperation {
datasourceId: string;
operation: ClipOperations;
options?: DatasourceOptions;
}
/** Optional parameters for polygon clip preprocessor */
export interface ClipOptions {
/** Ensures result is a polygon. If clip results in multipolygon, returns the largest component */
ensurePolygon?: boolean;
}
/**
* Returns true if feature is valid and meets requirements set by options.
* @param feature - feature to validate
* @param options - validation options
* @param options.minSize - minimum size in square kilometers that polygon can be. Throws if smaller.
* @param options.enforceMinSize - Whether or not minSize should be enforced and throw if smaller
* @param options.maxSize - maxSize in square kilometers that polygon can be. Throws if larger.
* @param options.enforceMaxSize - Whether or not maxSize should be enforced and throw if larger
* @throws if polygon is invalid with reason
* @returns true if valid
*/
export function ensureValidPolygon(
feature: Feature,
options: {
allowSelfCrossing?: boolean;
minSize?: number;
enforceMinSize?: boolean;
maxSize?: number;
enforceMaxSize?: boolean;
} = {},
): boolean {
const {
/** If false will throw error if shape crosses itself */
allowSelfCrossing = false,
/** Minimum shape size in square kilometers, defaults to 0 */
minSize = 0,
/** If true will throw error if shape is less than minSize */
enforceMinSize = true,
/** Maximum shape size in square kilometers */
maxSize = 10_000_000,
/** If true will throw error if shape is more than maxSize */
enforceMaxSize = true,
} = options;
if (!isPolygonFeature(feature)) {
throw new ValidationError("Input must be a polygon");
}
if (!booleanValid(feature)) {
throw new ValidationError("Polygon feature is invalid");
}
if (allowSelfCrossing === false) {
const kinkPoints = kinks(feature);
if (kinkPoints.features.length > 0) {
throw new ValidationError("Your sketch polygon crosses itself");
}
}
const MIN_SIZE_SQ_METERS = minSize * 1_000_000;
const MAX_SIZE_SQ_METERS = maxSize * 1_000_000;
if (enforceMinSize && area(feature) < MIN_SIZE_SQ_METERS) {
throw new ValidationError(
`Shapes should be at least ${numberFormat(minSize > 1 ? minSize : MIN_SIZE_SQ_METERS)} square ${minSize > 1 ? "km" : "meters"} in size`,
);
}
if (enforceMaxSize && area(feature) > MAX_SIZE_SQ_METERS) {
throw new ValidationError(
`Shapes should be no more than ${numberFormat(maxSize)} square km in size`,
);
}
return true;
}
/**
* Returns a function that applies clip operations to a feature using other polygon features.
* @param operations - array of DatasourceClipOperations
* @param options - clip options
* @param options.ensurePolygon - if true always returns single polygon. If operations result in multiple polygons it returns the largest (defaults to true)
* @throws if a datasource fetch returns no features or if nothing remains of feature after clip operations
* @returns clipped polygon
*/
export const genClipToPolygonFeatures = (
clipOperations: FeatureClipOperation[],
options: ClipOptions = {},
) => {
const func = async (feature: Feature): Promise<Feature> => {
return clipToPolygonFeatures(feature, clipOperations, options);
};
return func;
};
/**
* Takes a Polygon feature and returns the portion remaining after performing clipOperations against one or more Polygon features
* @param feature - feature to clip
* @param clipOperations - array of DatasourceClipOperations
* @param options - clip options
* @param options.ensurePolygon - if true always returns single polygon. If operations result in multiple polygons it returns the largest (defaults to true)
* @throws if a datasource fetch returns no features or if nothing remains of feature after clip operations
* @returns clipped polygon
*/
export async function clipToPolygonFeatures(
feature: Feature,
clipOperations: FeatureClipOperation[],
options: ClipOptions = {},
): Promise<Feature<Polygon | MultiPolygon>> {
if (!isPolygonFeature(feature)) {
throw new ValidationError("Input must be a polygon");
}
const { ensurePolygon = true } = options;
let clipped: Feature<Polygon | MultiPolygon> | null = feature; // Start with whole feature
// Sequentially run clip operations. If operation returns null at some point, don't do any more ops
for (const clipOp of clipOperations) {
if (clipped !== null && clipOp.clipFeatures.length > 0) {
if (clipOp.operation === "intersection") {
clipped = clipMultiMerge(
clipped,
fc(clipOp.clipFeatures),
"intersection",
);
} else if (clipOp.operation === "difference") {
clipped = clip(fc([clipped, ...clipOp.clipFeatures]), "difference");
}
}
}
if (!clipped || area(clipped) === 0) {
throw new ValidationError("Feature is outside of boundary");
} else {
if (ensurePolygon && clipped.geometry.type === "MultiPolygon") {
// If multipolygon, keep only the biggest piece
const flattened = flatten(clipped);
let biggest = [0, 0];
for (let i = 0; i < flattened.features.length; i++) {
const a = area(flattened.features[i]);
if (a > biggest[0]) {
biggest = [a, i];
}
}
return flattened.features[biggest[1]] as Feature<Polygon>;
} else {
return clipped;
}
}
}
/**
* Returns a function that Takes a Polygon feature and returns the portion remaining after performing clipOperations against one or more datasources
* @param project - project client to use for accessing datasources
* @param clipOperations - array of DatasourceClipOperations
* @param options - clip options
* @param options.ensurePolygon - if true always returns single polygon. If operations result in multiple polygons it returns the largest (defaults to true)
* @throws if a datasource fetch returns no features or if nothing remains of feature after clip operations
* @returns clipped polygon
*/
export const genClipToPolygonDatasources = <P extends ProjectClientInterface>(
project: P,
clipOperations: DatasourceClipOperation[],
options: ClipOptions = {},
) => {
const func = async (
feature: Feature<Polygon | MultiPolygon>,
): Promise<Feature> => {
return clipToPolygonDatasources(project, feature, clipOperations, options);
};
return func;
};
/**
* Takes a Polygon feature and returns the portion remaining after performing clipOperations against one or more datasources
* @param project - project client to use for accessing datasources
* @param feature - feature to clip
* @param clipOperations - array of DatasourceClipOperations
* @param options - clip options
* @param options.ensurePolygon - if true always returns single polygon. If operations result in multiple polygons it returns the largest (defaults to true)
* @throws if a datasource fetch returns no features or if nothing remains of feature after clip operations
* @returns clipped polygon
*/
export async function clipToPolygonDatasources<
P extends ProjectClientInterface,
>(
project: P,
feature: Feature,
clipOperations: DatasourceClipOperation[],
options: ClipOptions = {},
): Promise<Feature<Polygon | MultiPolygon>> {
if (!isPolygonFeature(feature)) {
throw new ValidationError("Input must be a polygon");
}
const { ensurePolygon = true } = options;
let clipped: Feature<Polygon | MultiPolygon> | null = feature; // Start with whole feature
const featureOperations = await Promise.all(
clipOperations.map(async (o) => {
const ds = project.getDatasourceById(o.datasourceId);
if (!isInternalVectorDatasource(ds) && !isExternalVectorDatasource(ds)) {
throw new Error(`Expected vector datasource for ${ds.datasourceId}`);
}
const url = project.getDatasourceUrl(ds);
const featureBox = bbox(feature);
const clipFeatures = await getDatasourceFeatures(ds, url, {
...o.options,
bbox: featureBox,
});
if (!isPolygonFeatureArray(clipFeatures)) {
throw new Error("Expected array of Polygon features");
}
return {
clipFeatures,
operation: o.operation,
};
}),
);
// Sequentially run clip operations in order. If operation returns null at some point, don't do any more ops
for (const clipOp of featureOperations) {
if (clipped !== null && clipOp.clipFeatures.length > 0) {
if (clipOp.operation === "intersection") {
clipped = clipMultiMerge(
clipped,
fc(clipOp.clipFeatures),
"intersection",
);
} else if (clipOp.operation === "difference") {
clipped = clip(fc([clipped, ...clipOp.clipFeatures]), "difference");
}
}
}
if (!clipped || area(clipped) === 0) {
throw new ValidationError("Feature is outside of boundary");
} else {
if (ensurePolygon && clipped.geometry.type === "MultiPolygon") {
// If multipolygon, keep only the biggest piece
const flattened = flatten(clipped);
let biggest = [0, 0];
for (let i = 0; i < flattened.features.length; i++) {
const a = area(flattened.features[i]);
if (a > biggest[0]) {
biggest = [a, i];
}
}
return flattened.features[biggest[1]] as Feature<Polygon>;
} else {
return clipped;
}
}
}