diff --git a/README.md b/README.md index a2042ed..06659df 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/functions/src/handlers/extractions.test.ts b/functions/src/handlers/extractions.test.ts index d23a96c..9eb6bf8 100644 --- a/functions/src/handlers/extractions.test.ts +++ b/functions/src/handlers/extractions.test.ts @@ -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(), })); /** diff --git a/functions/src/handlers/extractions.ts b/functions/src/handlers/extractions.ts index aeec90c..3b26752 100644 --- a/functions/src/handlers/extractions.ts +++ b/functions/src/handlers/extractions.ts @@ -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'; @@ -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 @@ -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 { + const vertexCount = getGeometryPointCount(geometry.toJSON() as Record); + + 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 @@ -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[]); - return isProjectableGeometry(unioned) ? unioned : null; + return isProjectableGeometry(result) ? result : null; } /** @@ -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}`); diff --git a/src/components/FeatureGeometryEditor.tsx b/src/components/FeatureGeometryEditor.tsx index cee1f8e..50f6ecb 100644 --- a/src/components/FeatureGeometryEditor.tsx +++ b/src/components/FeatureGeometryEditor.tsx @@ -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'; @@ -40,6 +41,7 @@ import { canBufferDraftGeometries, canCutDraftGeometries, cutDraftGeometries, + toSinglePartDraftGeometries, } from './addFeatureDraftGeometry'; import { getDraftGeometriesFromGraphics, @@ -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 []; @@ -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], ); @@ -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; @@ -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(); }); }; diff --git a/src/components/MapContainer.tsx b/src/components/MapContainer.tsx index 1099764..80a6093 100644 --- a/src/components/MapContainer.tsx +++ b/src/components/MapContainer.tsx @@ -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) { @@ -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; + } + const promises: Promise[] = []; activeMap.layers.forEach((layer) => { if (layer.id.startsWith(`project-${currentProject}-`)) { @@ -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) @@ -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 diff --git a/src/components/addFeatureDraftGeometry.test.ts b/src/components/addFeatureDraftGeometry.test.ts index 8f8cb80..f5c4538 100644 --- a/src/components/addFeatureDraftGeometry.test.ts +++ b/src/components/addFeatureDraftGeometry.test.ts @@ -10,6 +10,7 @@ import { CUT_DRAFT_NOOP_ERROR, cutDraftGeometries, INVALID_BUFFER_DISTANCE_ERROR, + toSinglePartDraftGeometries, } from './addFeatureDraftGeometry'; const SR = new SpatialReference({ wkid: 26912 }); @@ -60,6 +61,60 @@ const verticalCut = new Polyline({ }); describe('addFeatureDraftGeometry helpers', () => { + it('splits disconnected multipart polygons into independently editable geometries', () => { + const multipart = new Polygon({ + rings: [ + [ + [0, 0], + [10, 0], + [10, 10], + [0, 10], + [0, 0], + ], + [ + [20, 0], + [30, 0], + [30, 10], + [20, 10], + [20, 0], + ], + ], + spatialReference: SR, + }); + + const parts = toSinglePartDraftGeometries([multipart]) as Polygon[]; + + expect(parts).toHaveLength(2); + expect(parts.map((part) => part.extent!.xmin).sort((a, b) => a - b)).toEqual([0, 20]); + }); + + it('preserves an interior polygon ring as a hole in its containing part', () => { + const polygonWithHole = new Polygon({ + rings: [ + [ + [0, 0], + [0, 20], + [20, 20], + [20, 0], + [0, 0], + ], + [ + [5, 5], + [15, 5], + [15, 15], + [5, 15], + [5, 5], + ], + ], + spatialReference: SR, + }); + + const parts = toSinglePartDraftGeometries([polygonWithHole]) as Polygon[]; + + expect(parts).toHaveLength(1); + expect(parts[0]!.rings).toHaveLength(2); + }); + it('only allows cutting when there is draft geometry for a line or polygon', () => { expect(canCutDraftGeometries('POINT', [])).toBe(false); expect(canCutDraftGeometries('POLY', [])).toBe(false); @@ -83,15 +138,12 @@ describe('addFeatureDraftGeometry helpers', () => { table: 'POLY', }); - const survivingPolygon = result.geometries[0] as Polygon; - const [ring] = survivingPolygon.rings; - const xValues = (ring?.map(([x]) => x) ?? []).filter((value): value is number => value != null); + const survivor = (result.geometries[0] as Polygon).extent!; expect(result.changed).toBe(true); expect(result.error).toBeNull(); - expect(xValues.length).toBeGreaterThan(0); - expect(Math.min(...xValues)).toBe(0); - expect(Math.max(...xValues)).toBe(6); + expect(result.geometries).toHaveLength(1); + expect([survivor.xmin, survivor.xmax]).toEqual([0, 6]); }); it('keeps the longest line segment after a cut', async () => { @@ -103,15 +155,12 @@ describe('addFeatureDraftGeometry helpers', () => { table: 'LINE', }); - const survivingLine = result.geometries[0] as Polyline; - const [path] = survivingLine.paths; - const xValues = (path?.map(([x]) => x) ?? []).filter((value): value is number => value != null); + const survivor = (result.geometries[0] as Polyline).extent!; expect(result.changed).toBe(true); expect(result.error).toBeNull(); - expect(xValues.length).toBeGreaterThan(0); - expect(Math.min(...xValues)).toBe(0); - expect(Math.max(...xValues)).toBe(6); + expect(result.geometries).toHaveLength(1); + expect([survivor.xmin, survivor.xmax]).toEqual([0, 6]); }); it('preserves untouched draft parts when only one part is cut', async () => { @@ -124,8 +173,73 @@ describe('addFeatureDraftGeometry helpers', () => { table: 'POLY', }); + const untouchedExtent = (result.geometries.at(-1) as Polygon).extent!; + expect(result.changed).toBe(true); - expect((result.geometries[1] as Polygon).toJSON()).toEqual(untouchedPolygon.toJSON()); + expect(result.geometries).toHaveLength(2); + expect([untouchedExtent.xmin, untouchedExtent.xmax]).toEqual([20, 30]); + }); + + it('keeps the largest piece when the rings are counter-clockwise', async () => { + const counterClockwisePolygon = new Polygon({ + rings: [ + [ + [0, 0], + [0, 4], + [10, 4], + [10, 0], + [0, 0], + ], + ], + spatialReference: SR, + }); + + const result = await cutDraftGeometries({ + geometries: [counterClockwisePolygon], + cutGeometry: verticalCut, + table: 'POLY', + }); + + const survivor = (result.geometries[0] as Polygon).extent!; + + expect(result.changed).toBe(true); + expect(result.geometries).toHaveLength(1); + expect([survivor.xmin, survivor.xmax]).toEqual([0, 6]); + }); + + it('preserves untouched parts of a multipart draft polygon', async () => { + const multipart = new Polygon({ + rings: [ + [ + [0, 0], + [10, 0], + [10, 4], + [0, 4], + [0, 0], + ], + [ + [20, 0], + [30, 0], + [30, 4], + [20, 4], + [20, 0], + ], + ], + spatialReference: SR, + }); + + const result = await cutDraftGeometries({ + geometries: [multipart], + cutGeometry: verticalCut, + table: 'POLY', + }); + + const extents = (result.geometries as Polygon[]).map((piece) => piece.extent!).sort((a, b) => a.xmin - b.xmin); + + expect(result.changed).toBe(true); + expect(result.geometries).toHaveLength(2); + expect([extents[0]!.xmin, extents[0]!.xmax]).toEqual([0, 6]); + expect([extents[1]!.xmin, extents[1]!.xmax]).toEqual([20, 30]); }); it('returns a no-op result when the cut does not split any draft geometry', async () => { diff --git a/src/components/addFeatureDraftGeometry.ts b/src/components/addFeatureDraftGeometry.ts index 13679e3..1a30bed 100644 --- a/src/components/addFeatureDraftGeometry.ts +++ b/src/components/addFeatureDraftGeometry.ts @@ -3,6 +3,7 @@ import * as areaOperator from '@arcgis/core/geometry/operators/areaOperator.js'; import * as cutOperator from '@arcgis/core/geometry/operators/cutOperator.js'; import * as geodesicBufferOperator from '@arcgis/core/geometry/operators/geodesicBufferOperator.js'; import * as lengthOperator from '@arcgis/core/geometry/operators/lengthOperator.js'; +import * as multiPartToSinglePartOperator from '@arcgis/core/geometry/operators/multiPartToSinglePartOperator.js'; import type Polygon from '@arcgis/core/geometry/Polygon.js'; import type Polyline from '@arcgis/core/geometry/Polyline.js'; import type { FeatureTable } from '@ugrc/wri-shared/types'; @@ -62,23 +63,21 @@ export const canBufferDraftGeometries = (table: FeatureTable | undefined, geomet return table === 'POLY' && geometries.some(isPolylineGeometry); }; -const chooseDominantGeometry = ( - pieces: SupportedDraftGeometry[], - table: CuttableFeatureTable, -): SupportedDraftGeometry | null => { - let bestGeometry: SupportedDraftGeometry | null = null; - let bestScore = Number.NEGATIVE_INFINITY; - - for (const piece of pieces) { - const score = table === 'POLY' ? areaOperator.execute(piece as Polygon) : lengthOperator.execute(piece as Polyline); +// areaOperator returns a signed value, so counter-clockwise rings would otherwise score lowest. +const measurePiece = (piece: SupportedDraftGeometry, table: CuttableFeatureTable): number => { + return table === 'POLY' + ? Math.abs(areaOperator.execute(piece as Polygon)) + : Math.abs(lengthOperator.execute(piece as Polyline)); +}; - if (score > bestScore) { - bestScore = score; - bestGeometry = piece; +export const toSinglePartDraftGeometries = (geometries: Geometry[]): Geometry[] => { + return geometries.flatMap((geometry) => { + if (!isPolygonGeometry(geometry) && !isPolylineGeometry(geometry)) { + return [geometry]; } - } - return bestGeometry; + return multiPartToSinglePartOperator.executeMany([geometry]) as SupportedDraftGeometry[]; + }); }; export const cutDraftGeometries = ({ @@ -88,31 +87,39 @@ export const cutDraftGeometries = ({ }: CutDraftGeometriesParams): CutDraftGeometriesResult => { let changed = false; - const nextGeometries = geometries.map((geometry) => { - const currentGeometry = geometry as SupportedDraftGeometry; - const pieces = (cutOperator.execute(currentGeometry, cutGeometry) ?? []).filter( - (piece) => piece != null, - ) as SupportedDraftGeometry[]; + const nextGeometries = geometries.flatMap((geometry): Geometry[] => { + // cutOperator groups every left-side part into a single output geometry, so a multipart draft + // has to be cut one part at a time to keep parts the cut line never touched. + return (toSinglePartDraftGeometries([geometry]) as SupportedDraftGeometry[]).flatMap((part): Geometry[] => { + const pieces = (cutOperator.execute(part, cutGeometry) ?? []) + .filter((piece): piece is SupportedDraftGeometry => piece != null) + .map((piece) => ({ piece, size: measurePiece(piece, table) })) + .filter(({ size }) => size > 0); - if (pieces.length < 2) { - return geometry; - } - - const dominantGeometry = chooseDominantGeometry(pieces, table); + if (pieces.length < 2) { + return [part]; + } - if (!dominantGeometry) { - throw new Error('Unable to determine which cut geometry should survive.'); - } + changed = true; - changed = true; + const survivor = pieces.reduce((best, candidate) => (candidate.size > best.size ? candidate : best)); - return dominantGeometry; + return [survivor.piece]; + }); }); + if (!changed) { + return { + geometries, + changed: false, + error: CUT_DRAFT_NOOP_ERROR, + }; + } + return { geometries: nextGeometries, - changed, - error: changed ? null : CUT_DRAFT_NOOP_ERROR, + changed: true, + error: null, }; };