Skip to content
7 changes: 7 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,13 @@ Staging: [wri-stage.at.utah.gov](https://wri-stage.at.utah.gov)

Set `DEV_USER_EMAIL` in `.env.local` if you want `npm start` to inject your local edit credentials from the dev database.

### Running Locally

1. Run `gcloud auth application-default login` and authenticate
1. Run `cloud-sql-proxy ut-dnr-dwr-wri-app-at:us-west3:nrgcwridbt -p 7777`
1. In a new tab, make sure you are in the WRI repo, and run `npm start`
1. Navigate to http://localhost:5173/

### Publishing Services to ArcGIS Server

#### Export Web Map
Expand Down
2 changes: 1 addition & 1 deletion functions/src/handlers/extractions.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ vi.mock('@arcgis/core/geometry/operators/intersectionOperator.js', () => ({
}));

vi.mock('@arcgis/core/geometry/operators/unionOperator.js', () => ({
execute: vi.fn(),
executeMany: vi.fn(),
}));

/**
Expand Down
110 changes: 84 additions & 26 deletions functions/src/handlers/extractions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import {
load as projectLoad,
executeMany as projectMany,
} from '@arcgis/core/geometry/operators/projectOperator.js';
import { execute as union } from '@arcgis/core/geometry/operators/unionOperator.js';
import { executeMany as unionMany } from '@arcgis/core/geometry/operators/unionOperator.js';
import type Polygon from '@arcgis/core/geometry/Polygon.js';
import type Polyline from '@arcgis/core/geometry/Polyline.js';
import SpatialReference from '@arcgis/core/geometry/SpatialReference.js';
Expand All @@ -26,6 +26,12 @@ type ProjectableGeometry = Polygon | Polyline | Multipoint;

const FEATURE_QUERY_TIMEOUT_MS = 60000;

// Geometries denser than this (e.g. shapefile uploads with many vertices) are queried against
// external feature services via their bounding box instead of their exact shape — sending every
// vertex of a very dense geometry can be slow or fail outright. Candidate features found this way
// are always re-checked with an exact intersects test against the full-resolution geometry.
const FEATURE_QUERY_VERTEX_THRESHOLD = 5000;

type SerializableParam =
| string
| number
Expand Down Expand Up @@ -366,6 +372,76 @@ export async function queryFeatureService(
return { features: converted } as IQueryFeaturesResponse;
}

/**
* Builds a rectangular polygon covering a geometry's bounding box, for use as a spatial pre-filter.
* The bbox always fully contains the source geometry, so it can never exclude a true intersection —
* at most it returns extra candidates, which the exact `calculateIntersection` check filters out later.
* @param geometry - Geometry to compute a bounding box for
* @returns A rectangular polygon matching the geometry's extent, or null if no extent is available
*/
function buildBoundingBoxQueryGeometry(geometry: ProjectableGeometry): ProjectableGeometry | null {
const extent = geometry.extent;

if (!extent) {
return null;
}

const spatialReference = extent.spatialReference?.toJSON();
const ring = [
[extent.xmin, extent.ymin],
[extent.xmax, extent.ymin],
[extent.xmax, extent.ymax],
[extent.xmin, extent.ymax],
[extent.xmin, extent.ymin],
];

const bbox = geometryFromJSON({ rings: [ring], spatialReference } as unknown as IGeometry);

return isProjectableGeometry(bbox) ? bbox : null;
}

/**
* Queries a feature service for candidate features that might intersect a geometry.
* Dense geometries (e.g. shapefile uploads with many vertices) are queried via their bounding
* box instead of their exact shape, since sending every vertex to an external service can be slow
* or fail outright. This never misses a true intersection — the caller always re-checks candidates
* against the original full-resolution geometry with `calculateIntersection`.
* @param serviceUrl - URL of the feature service layer
* @param geometry - Full-resolution input geometry to find candidates for
* @param outFields - Array of field names to return
* @returns Candidate feature graphics
*/
export async function queryFeatureServiceForCandidates(
serviceUrl: string,
geometry: ProjectableGeometry,
outFields: string[],
): Promise<Graphic[]> {
const vertexCount = getGeometryPointCount(geometry.toJSON() as Record<string, unknown>);

let queryGeometry = geometry;

if (vertexCount > FEATURE_QUERY_VERTEX_THRESHOLD) {
const bbox = buildBoundingBoxQueryGeometry(geometry);

if (bbox) {
logger.info('Using bounding-box pre-filter for dense geometry feature service query', {
serviceUrl,
vertexCount,
});
queryGeometry = bbox;
} else {
logger.warn('Failed to compute bounding box for dense geometry, querying exact geometry instead', {
serviceUrl,
vertexCount,
});
}
}

const featureSet = await queryFeatureService(serviceUrl, queryGeometry, outFields);

return (featureSet.features as Graphic[]) || [];
}

/**
* Projects geometries to a different spatial reference using client-side projection
* @param geometries - Array of geometries to project
Expand Down Expand Up @@ -403,29 +479,11 @@ export async function unionGeometries(geometries: ProjectableGeometry[]): Promis

logger.debug('Performing a union on geometries', { count: geometries.length });

// Union geometries pairwise
let unioned = geometries[0];
for (let i = 1; i < geometries.length; i++) {
const nextGeom = geometries[i];

if (!unioned || !nextGeom) {
return null;
}

const result = union(unioned, nextGeom);

if (!result) {
return null;
}

if (!isProjectableGeometry(result)) {
return null;
}

unioned = result;
}
// executeMany unions the whole set in one optimized pass — the naive pairwise loop this replaced
// grows increasingly slow as `unioned` accumulates complexity, and times out on large geometry sets.
const result = unionMany(geometries as GeometryUnion[]);
Comment thread
acneville marked this conversation as resolved.

return isProjectableGeometry(unioned) ? unioned : null;
return isProjectableGeometry(result) ? result : null;
}

/**
Expand Down Expand Up @@ -562,9 +620,9 @@ export async function extractIntersections(
logger.debug(`Processing layer: ${layer}`);

try {
// Response features are returned in UTM Zone 12N (26912) as specified by outSR parameter
const featureSet = await queryFeatureService(config.url, clip, layerCriteria.attributes);
const graphics = (featureSet && (featureSet.features as Graphic[])) || [];
// Response features are returned in UTM Zone 12N (26912) as specified by outSR parameter.
// Uses the full-resolution `clip`; large geometries are transparently chunked internally.
const graphics = await queryFeatureServiceForCandidates(config.url, clip, layerCriteria.attributes);

if (graphics.length === 0) {
logger.debug(`No intersections found for layer: ${layer}`);
Expand Down
47 changes: 45 additions & 2 deletions src/components/FeatureGeometryEditor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import Collection from '@arcgis/core/core/Collection.js';
import type { ResourceHandle } from '@arcgis/core/core/Handles.js';
import { watch } from '@arcgis/core/core/reactiveUtils';
import type Geometry from '@arcgis/core/geometry/Geometry.js';
import type Multipoint from '@arcgis/core/geometry/Multipoint.js';
import type Polyline from '@arcgis/core/geometry/Polyline.js';
import Graphic from '@arcgis/core/Graphic.js';
import type FeatureLayer from '@arcgis/core/layers/FeatureLayer.js';
Expand Down Expand Up @@ -40,6 +41,7 @@ import {
canBufferDraftGeometries,
canCutDraftGeometries,
cutDraftGeometries,
toSinglePartDraftGeometries,
} from './addFeatureDraftGeometry';
import {
getDraftGeometriesFromGraphics,
Expand Down Expand Up @@ -91,6 +93,28 @@ const getDefaultSymbol = (geometry: Geometry) => {
}
};

// Detects a sketch graphic that has no meaningful vertices (e.g. an auto-started
// continuous-mode create operation that was force-completed before the user drew anything).
const isDegenerateGraphic = (graphic: Graphic | null | undefined): boolean => {
const geometry = graphic?.geometry;

if (!geometry) {
return true;
}

if (geometry.type === 'multipoint') {
return (geometry as Multipoint).points.length === 0;
}

if (geometry.type === 'point') {
return false;
}

const extent = geometry.extent;

return !extent || (extent.width === 0 && extent.height === 0);
};

const normalizeInitialGeometries = (initialGeometry?: Geometry | Geometry[] | null): Geometry[] => {
if (!initialGeometry) {
return [];
Expand Down Expand Up @@ -287,8 +311,14 @@ export default function FeatureGeometryEditor({
return;
}

replaceDraftLayerGraphics(graphicsLayer, nextGeometries, (geometry) => getDefaultSymbol(geometry) ?? undefined);
setDraftGeometryState(nextGeometries);
const singlePartGeometries = toSinglePartDraftGeometries(nextGeometries);

replaceDraftLayerGraphics(
graphicsLayer,
singlePartGeometries,
(geometry) => getDefaultSymbol(geometry) ?? undefined,
);
setDraftGeometryState(singlePartGeometries);
},
[setDraftGeometryState],
);
Expand Down Expand Up @@ -429,6 +459,8 @@ export default function FeatureGeometryEditor({
}

if (event.detail.state === 'active') {
// keep geometry state live during the drag so a submit mid-drag never uses stale geometry
syncGraphicsFromLayer();
setSelectedDraftCount(event.detail.graphics.length);

return;
Expand Down Expand Up @@ -838,7 +870,18 @@ export default function FeatureGeometryEditor({
return;
}

// Continuous drawing mode auto-starts a new, empty sketch after every shape completes.
// Completing that empty sketch would add a degenerate zero-vertex graphic, so cancel it instead.
const shouldDiscardActiveSketch = isDegenerateGraphic(sketch.createGraphic);

sketch.creationMode = 'single';

if (shouldDiscardActiveSketch) {
void sketch.cancel();

return;
}

void sketch.complete();
});
};
Expand Down
34 changes: 34 additions & 0 deletions src/components/MapContainer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -162,6 +162,8 @@ export const MapContainer = () => {
{ name: 'Title', type: 'string' as const },
];
const outFields = outFieldDefs.map((f) => f.name);
let cancelled = false;
const addedLayerIds: string[] = [];

const getFeatures = async () => {
for (const layer of operationalLayers.current) {
Expand Down Expand Up @@ -219,12 +221,29 @@ export const MapContainer = () => {
spatialReference: layer.spatialReference,
});

if (cancelled) {
return;
}

// guard against duplicate layers from a prior run of this effect (e.g. StrictMode's
// dev-mode double-invoke) so edits always land on the single layer actually being rendered
const staleLayer = activeMap.findLayerById(featureLayer.id);
if (staleLayer) {
activeMap.remove(staleLayer);
staleLayer.destroy();
}

activeMap.add(featureLayer);
addedLayerIds.push(featureLayer.id);
}
};

getFeatures()
.then(() => {
if (cancelled) {
return;
}
Comment thread
acneville marked this conversation as resolved.

const promises: Promise<ExtentQueryResult>[] = [];
activeMap.layers.forEach((layer) => {
if (layer.id.startsWith(`project-${currentProject}-`)) {
Expand All @@ -238,6 +257,10 @@ export const MapContainer = () => {
});

Promise.all(promises).then((results) => {
if (cancelled) {
return;
}

let combinedExtent: Extent | null = null;
results
.filter((x): x is ExtentQueryResult & { extent: Extent } => x.count > 0 && x.extent != null)
Expand All @@ -257,6 +280,17 @@ export const MapContainer = () => {
.catch((error) => {
console.error('Error fetching features for operational layers:', error);
});

return () => {
cancelled = true;
addedLayerIds.forEach((id) => {
const addedLayer = activeMap.findLayerById(id);
if (addedLayer) {
activeMap.remove(addedLayer);
addedLayer.destroy();
}
});
};
}, [currentProject, isReady, layersReady]);

// remove project specific layers when the project changes
Expand Down
Loading
Loading