From a1a1bf56081c4f5f3f87b4e09d813ed0344d8f8f Mon Sep 17 00:00:00 2001 From: Alex Neville Date: Fri, 18 Sep 2026 11:42:06 -0600 Subject: [PATCH 01/10] docs: readme update --- README.md | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/README.md b/README.md index a2042ed..e9a5804 100644 --- a/README.md +++ b/README.md @@ -16,6 +16,12 @@ 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, run `npm run start` + ### Publishing Services to ArcGIS Server #### Export Web Map From 94be96f8cd8b2de2460f7ae4dea6aebafb4fc68a Mon Sep 17 00:00:00 2001 From: Alex Neville Date: Fri, 18 Sep 2026 17:24:51 -0600 Subject: [PATCH 02/10] fix: map state --- README.md | 3 ++- src/components/FeatureGeometryEditor.tsx | 2 ++ src/components/MapContainer.tsx | 30 ++++++++++++++++++++++++ 3 files changed, 34 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index e9a5804..c4687b5 100644 --- a/README.md +++ b/README.md @@ -20,7 +20,8 @@ Set `DEV_USER_EMAIL` in `.env.local` if you want `npm start` to inject your loca 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, run `npm run start` +1. In a new tab, run `npm start` +1. Navigate to http://localhost:5173/ ### Publishing Services to ArcGIS Server diff --git a/src/components/FeatureGeometryEditor.tsx b/src/components/FeatureGeometryEditor.tsx index cee1f8e..5eadfaa 100644 --- a/src/components/FeatureGeometryEditor.tsx +++ b/src/components/FeatureGeometryEditor.tsx @@ -429,6 +429,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; diff --git a/src/components/MapContainer.tsx b/src/components/MapContainer.tsx index 1099764..f9f91b0 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}-`)) { @@ -257,6 +276,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 From 7496532ccc286171db39d92a424748087b9fd9df Mon Sep 17 00:00:00 2001 From: Alex Neville Date: Tue, 22 Sep 2026 14:41:20 -0600 Subject: [PATCH 03/10] fix: use cancel instead of complete if incomplete geometry exists --- src/components/FeatureGeometryEditor.tsx | 34 ++++++++++++++++++++++++ 1 file changed, 34 insertions(+) diff --git a/src/components/FeatureGeometryEditor.tsx b/src/components/FeatureGeometryEditor.tsx index 5eadfaa..88f179c 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'; @@ -91,6 +92,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 []; @@ -840,7 +863,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(); }); }; From 430e061545b1310c1ebf658fd7c2e9ca741d9828 Mon Sep 17 00:00:00 2001 From: Alex Neville Date: Tue, 22 Sep 2026 15:42:11 -0600 Subject: [PATCH 04/10] fix: multi-polygon cuts and fixing negative value error which would delete entire shapes --- .../addFeatureDraftGeometry.test.ts | 85 ++++++++++++++++--- src/components/addFeatureDraftGeometry.ts | 67 ++++++++------- 2 files changed, 106 insertions(+), 46 deletions(-) diff --git a/src/components/addFeatureDraftGeometry.test.ts b/src/components/addFeatureDraftGeometry.test.ts index 8f8cb80..5890c40 100644 --- a/src/components/addFeatureDraftGeometry.test.ts +++ b/src/components/addFeatureDraftGeometry.test.ts @@ -83,15 +83,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 +100,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 +118,73 @@ describe('addFeatureDraftGeometry helpers', () => { table: 'POLY', }); + const untouchedExtent = (result.geometries.at(-1) as Polygon).extent!; + + expect(result.changed).toBe(true); + 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[1] as Polygon).toJSON()).toEqual(untouchedPolygon.toJSON()); + 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..91b895c 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,15 @@ 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); - - if (score > bestScore) { - bestScore = score; - bestGeometry = piece; - } - } +// 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)); +}; - return bestGeometry; +const toSingleParts = (geometry: Geometry): SupportedDraftGeometry[] => { + return multiPartToSinglePartOperator.executeMany([geometry as SupportedDraftGeometry]) as SupportedDraftGeometry[]; }; export const cutDraftGeometries = ({ @@ -88,31 +81,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[]; - - if (pieces.length < 2) { - return geometry; - } + 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 toSingleParts(geometry).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); - 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, }; }; From 86d5e54a407e42515bd5fbf463fd090b1af4fab1 Mon Sep 17 00:00:00 2001 From: Alex Neville Date: Wed, 23 Sep 2026 13:52:57 -0600 Subject: [PATCH 05/10] fix: geometric query simplification to accommodate larger shapefiles --- functions/src/handlers/extractions.ts | 35 ++++++++++++++++++++++++++- 1 file changed, 34 insertions(+), 1 deletion(-) diff --git a/functions/src/handlers/extractions.ts b/functions/src/handlers/extractions.ts index aeec90c..24611f9 100644 --- a/functions/src/handlers/extractions.ts +++ b/functions/src/handlers/extractions.ts @@ -1,5 +1,6 @@ import type Multipoint from '@arcgis/core/geometry/Multipoint.js'; import { execute as area } from '@arcgis/core/geometry/operators/areaOperator.js'; +import { execute as generalize } from '@arcgis/core/geometry/operators/generalizeOperator.js'; import { accelerateGeometry, execute as intersect } from '@arcgis/core/geometry/operators/intersectionOperator.js'; import { execute as length } from '@arcgis/core/geometry/operators/lengthOperator.js'; import { @@ -26,6 +27,13 @@ type ProjectableGeometry = Polygon | Polyline | Multipoint; const FEATURE_QUERY_TIMEOUT_MS = 60000; +// Geometries denser than this (e.g. shapefile uploads with many vertices) are generalized before +// being sent to external feature services, which can reject or fail on very large request bodies. +const FEATURE_QUERY_VERTEX_THRESHOLD = 2000; +// Use aggressive simplification (50m deviation) to significantly reduce geometry complexity for external service queries. +// This doesn't affect accuracy of intersection calculations (which use the full-resolution geometry). +const FEATURE_QUERY_GENERALIZE_MAX_DEVIATION_METERS = 50; + type SerializableParam = | string | number @@ -548,6 +556,31 @@ export async function extractIntersections( await accelerateGeometry(clip); + // Use a generalized copy of the clip geometry for the external feature service queries only. + // Intersection area/length calculations below still use the full-resolution `clip` geometry. + const clipJson = clip.toJSON() as Record; + const clipVertexCount = getGeometryPointCount(clipJson); + + let queryGeometry: ProjectableGeometry = clip; + if (clipVertexCount > FEATURE_QUERY_VERTEX_THRESHOLD) { + const generalized = generalize(clip as GeometryUnion, FEATURE_QUERY_GENERALIZE_MAX_DEVIATION_METERS); + if (generalized && isProjectableGeometry(generalized)) { + queryGeometry = generalized; + const generalizedJson = generalized.toJSON() as Record; + const generalizedVertexCount = getGeometryPointCount(generalizedJson); + const reductionPercent = (((clipVertexCount - generalizedVertexCount) / clipVertexCount) * 100).toFixed(1); + logger.info('Geometry simplified for feature service queries', { + originalVertices: clipVertexCount, + simplifiedVertices: generalizedVertexCount, + reductionPercent, + }); + } else { + logger.warn('Geometry simplification failed, using original', { + vertexCount: clipVertexCount, + }); + } + } + // Process each layer in the criteria for (const [layerName, layerCriteria] of Object.entries(criteria)) { const layer = layerName as LayerName; @@ -563,7 +596,7 @@ export async function extractIntersections( 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 featureSet = await queryFeatureService(config.url, queryGeometry, layerCriteria.attributes); const graphics = (featureSet && (featureSet.features as Graphic[])) || []; if (graphics.length === 0) { From f0c49bd4091a9d27dcaaa99095add4423a6b7220 Mon Sep 17 00:00:00 2001 From: Alex Neville Date: Wed, 23 Sep 2026 16:50:39 -0600 Subject: [PATCH 06/10] chore: adding extra cancelled check for quick switch race --- src/components/MapContainer.tsx | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/components/MapContainer.tsx b/src/components/MapContainer.tsx index f9f91b0..80a6093 100644 --- a/src/components/MapContainer.tsx +++ b/src/components/MapContainer.tsx @@ -257,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) From 5b16a0bf9fdb2ce6b1dc5c203ce3104f01434098 Mon Sep 17 00:00:00 2001 From: Alex Neville Date: Thu, 24 Sep 2026 09:47:17 -0600 Subject: [PATCH 07/10] docs: update README --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index c4687b5..06659df 100644 --- a/README.md +++ b/README.md @@ -20,7 +20,7 @@ Set `DEV_USER_EMAIL` in `.env.local` if you want `npm start` to inject your loca 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, run `npm start` +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 From 2c9a5f2f2efc0a8709226f06ce0ca0ccf96c0330 Mon Sep 17 00:00:00 2001 From: Alex Neville Date: Thu, 24 Sep 2026 15:35:37 -0600 Subject: [PATCH 08/10] feat: selecting disparate polygons --- src/components/FeatureGeometryEditor.tsx | 11 +++- .../addFeatureDraftGeometry.test.ts | 55 +++++++++++++++++++ src/components/addFeatureDraftGeometry.ts | 12 +++- 3 files changed, 73 insertions(+), 5 deletions(-) diff --git a/src/components/FeatureGeometryEditor.tsx b/src/components/FeatureGeometryEditor.tsx index 88f179c..50f6ecb 100644 --- a/src/components/FeatureGeometryEditor.tsx +++ b/src/components/FeatureGeometryEditor.tsx @@ -41,6 +41,7 @@ import { canBufferDraftGeometries, canCutDraftGeometries, cutDraftGeometries, + toSinglePartDraftGeometries, } from './addFeatureDraftGeometry'; import { getDraftGeometriesFromGraphics, @@ -310,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], ); diff --git a/src/components/addFeatureDraftGeometry.test.ts b/src/components/addFeatureDraftGeometry.test.ts index 5890c40..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); diff --git a/src/components/addFeatureDraftGeometry.ts b/src/components/addFeatureDraftGeometry.ts index 91b895c..1a30bed 100644 --- a/src/components/addFeatureDraftGeometry.ts +++ b/src/components/addFeatureDraftGeometry.ts @@ -70,8 +70,14 @@ const measurePiece = (piece: SupportedDraftGeometry, table: CuttableFeatureTable : Math.abs(lengthOperator.execute(piece as Polyline)); }; -const toSingleParts = (geometry: Geometry): SupportedDraftGeometry[] => { - return multiPartToSinglePartOperator.executeMany([geometry as SupportedDraftGeometry]) as SupportedDraftGeometry[]; +export const toSinglePartDraftGeometries = (geometries: Geometry[]): Geometry[] => { + return geometries.flatMap((geometry) => { + if (!isPolygonGeometry(geometry) && !isPolylineGeometry(geometry)) { + return [geometry]; + } + + return multiPartToSinglePartOperator.executeMany([geometry]) as SupportedDraftGeometry[]; + }); }; export const cutDraftGeometries = ({ @@ -84,7 +90,7 @@ export const cutDraftGeometries = ({ 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 toSingleParts(geometry).flatMap((part): Geometry[] => { + 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) })) From e872ccea6e83ebdebabf4ca578dd21d8f6d95fc5 Mon Sep 17 00:00:00 2001 From: Alex Neville Date: Fri, 25 Sep 2026 13:57:03 -0600 Subject: [PATCH 09/10] fix: using bbox to handle large geometries --- functions/src/handlers/extractions.test.ts | 2 +- functions/src/handlers/extractions.ts | 141 ++++++++++++--------- 2 files changed, 84 insertions(+), 59 deletions(-) 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 24611f9..89e7fa0 100644 --- a/functions/src/handlers/extractions.ts +++ b/functions/src/handlers/extractions.ts @@ -1,6 +1,5 @@ import type Multipoint from '@arcgis/core/geometry/Multipoint.js'; import { execute as area } from '@arcgis/core/geometry/operators/areaOperator.js'; -import { execute as generalize } from '@arcgis/core/geometry/operators/generalizeOperator.js'; import { accelerateGeometry, execute as intersect } from '@arcgis/core/geometry/operators/intersectionOperator.js'; import { execute as length } from '@arcgis/core/geometry/operators/lengthOperator.js'; import { @@ -8,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'; @@ -27,12 +26,11 @@ type ProjectableGeometry = Polygon | Polyline | Multipoint; const FEATURE_QUERY_TIMEOUT_MS = 60000; -// Geometries denser than this (e.g. shapefile uploads with many vertices) are generalized before -// being sent to external feature services, which can reject or fail on very large request bodies. -const FEATURE_QUERY_VERTEX_THRESHOLD = 2000; -// Use aggressive simplification (50m deviation) to significantly reduce geometry complexity for external service queries. -// This doesn't affect accuracy of intersection calculations (which use the full-resolution geometry). -const FEATURE_QUERY_GENERALIZE_MAX_DEVIATION_METERS = 50; +// 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 @@ -374,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 @@ -411,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; - } + // 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[]); - unioned = result; - } - - return isProjectableGeometry(unioned) ? unioned : null; + return result && isProjectableGeometry(result) ? result : null; } /** @@ -556,31 +606,6 @@ export async function extractIntersections( await accelerateGeometry(clip); - // Use a generalized copy of the clip geometry for the external feature service queries only. - // Intersection area/length calculations below still use the full-resolution `clip` geometry. - const clipJson = clip.toJSON() as Record; - const clipVertexCount = getGeometryPointCount(clipJson); - - let queryGeometry: ProjectableGeometry = clip; - if (clipVertexCount > FEATURE_QUERY_VERTEX_THRESHOLD) { - const generalized = generalize(clip as GeometryUnion, FEATURE_QUERY_GENERALIZE_MAX_DEVIATION_METERS); - if (generalized && isProjectableGeometry(generalized)) { - queryGeometry = generalized; - const generalizedJson = generalized.toJSON() as Record; - const generalizedVertexCount = getGeometryPointCount(generalizedJson); - const reductionPercent = (((clipVertexCount - generalizedVertexCount) / clipVertexCount) * 100).toFixed(1); - logger.info('Geometry simplified for feature service queries', { - originalVertices: clipVertexCount, - simplifiedVertices: generalizedVertexCount, - reductionPercent, - }); - } else { - logger.warn('Geometry simplification failed, using original', { - vertexCount: clipVertexCount, - }); - } - } - // Process each layer in the criteria for (const [layerName, layerCriteria] of Object.entries(criteria)) { const layer = layerName as LayerName; @@ -595,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, queryGeometry, 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}`); From b66e2286a5956f976a6b888c857407edb1abb70b Mon Sep 17 00:00:00 2001 From: Alex Neville Date: Mon, 28 Sep 2026 13:25:05 -0600 Subject: [PATCH 10/10] refactor: removed redundant check --- functions/src/handlers/extractions.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/functions/src/handlers/extractions.ts b/functions/src/handlers/extractions.ts index 89e7fa0..3b26752 100644 --- a/functions/src/handlers/extractions.ts +++ b/functions/src/handlers/extractions.ts @@ -483,7 +483,7 @@ export async function unionGeometries(geometries: ProjectableGeometry[]): Promis // grows increasingly slow as `unioned` accumulates complexity, and times out on large geometry sets. const result = unionMany(geometries as GeometryUnion[]); - return result && isProjectableGeometry(result) ? result : null; + return isProjectableGeometry(result) ? result : null; } /**