From a8da821c82b1c527585e8eeac0956b51f5c3aef0 Mon Sep 17 00:00:00 2001
From: Corey Krewson
Date: Mon, 24 Aug 2026 15:07:51 -0700
Subject: [PATCH 01/18] feat(map): register coordinate reference systems layers
name by code
Adds a projection table for CRSes OpenLayers cannot resolve on its own,
registered at module evaluation so it is in place before layers construct.
Layers are built concurrently, so a registration that waited on anything
async would race them.
Registration is scoped deliberately. `register` builds pairwise transforms
across every registered code, so its cost is quadratic -- 99ms for two
definitions on top of proj4's built-ins, and hundreds of milliseconds at a
State-Plane-sized set. This module is a static import, so an unbounded init
set would be a first-render regression on every dashboard. Only codes a
layer actually names are registered up front; `ensureProjection` handles the
rest on demand.
Definitions, extents and control points come from PROJ's EPSG database
rather than being hand-derived, and the control points sit away from each
projection's origin so they exercise the standard parallels and scale
factor. proj4 agrees with PROJ on both to sub-millimetre, which makes the
round-trip test a cross-implementation check rather than a self-consistency
one.
Three behaviors worth naming, each found by measurement:
- `register` cannot supply an extent, so extents are applied afterward.
- A definition is validated before being registered, not after: `register`
constructs a transform for every pair of registered codes, so one unusable
definition makes it throw and takes working projections down with it. The
probe uses the definition's own centre, since a fixed point is outside many
projections' domains.
- proj4 implements the ESRI spelling of Albers but not the OGC one, which
fails silently with non-finite coordinates. Now reported rather than
rendering features nowhere.
A layer's own WKT never overwrites a definition that already resolves. The
registry is global to the browser session, so letting one layer's parameters
replace a code others resolve through would make rendering depend on which
dashboard was opened first. Unresolvable WKT registers under a synthetic
code, never under a claimed authority code.
The raster auto-fit will not adopt a registered-but-not-native projection as
the view projection. Adoption calls setView and publishes the adopted code
into the map-extent variable other visualizations read; widening it is a
separate change with its own verification. Such a raster still renders, by
reprojection.
Dependencies for the whole feature land here rather than across three
commits: proj4, shapefile, fflate, @mapbox/geojson-rewind, wkt-parser. All
exact-pinned; fflate matches the version already resolved transitively to
avoid a duplicate install. wkt-parser is declared directly because proj4
does not re-export its parser and the outermost AUTHORITY node is needed.
Co-Authored-By: Claude Opus 5 (1M context)
---
package-lock.json | 112 +++++++-
package.json | 7 +-
reactapp/__tests__/components/map/Map.test.js | 98 ++++++-
.../components/map/projections.test.js | 196 +++++++++++++
reactapp/components/map/Map.js | 26 +-
reactapp/components/map/projections.js | 264 ++++++++++++++++++
6 files changed, 692 insertions(+), 11 deletions(-)
create mode 100644 reactapp/__tests__/components/map/projections.test.js
create mode 100644 reactapp/components/map/projections.js
diff --git a/package-lock.json b/package-lock.json
index 28f92993..de5a1b20 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -9,6 +9,7 @@
"version": "0.16.10",
"license": "ISC",
"dependencies": {
+ "@mapbox/geojson-rewind": "0.5.2",
"@mapbox/vector-tile": "^1.3.1",
"@tiptap/extension-color": "^2.12.0",
"@tiptap/extension-font-family": "^2.12.0",
@@ -32,6 +33,7 @@
"date-fns": "^4.1.0",
"dompurify": "^3.1.6",
"dotenv": "^16.0.1",
+ "fflate": "0.8.2",
"file-loader": "^6.2.0",
"geotiff": "2.1.3",
"html-react-parser": "^5.1.18",
@@ -42,6 +44,7 @@
"ol-pmtiles": "^2.0.2",
"plotly.js-strict-dist-min": "^2.35.2",
"prismjs": "^1.28.0",
+ "proj4": "2.21.0",
"rc-slider": "^11.1.8",
"react": "^18.3.1",
"react-bootstrap": "^2.10.2",
@@ -62,13 +65,15 @@
"react-use-websocket": "^4.13.0",
"sass": "^1.49.0",
"sass-loader": "^12.3.0",
+ "shapefile": "0.6.6",
"simple-xml-to-json": "^1.2.3",
"style-loader": "^3.3.1",
"styled-components": "^6.3.9",
"swiper": "^11.2.1",
"uuid": "^8.3.2",
"webpack": "^5.64.4",
- "webpack-dev-server": "^4.6.0"
+ "webpack-dev-server": "^4.6.0",
+ "wkt-parser": "1.5.6"
},
"devDependencies": {
"@babel/core": "^7.18.2",
@@ -3579,7 +3584,6 @@
"resolved": "https://registry.npmjs.org/@mapbox/geojson-rewind/-/geojson-rewind-0.5.2.tgz",
"integrity": "sha512-tJaT+RbYGJYStt7wI3cq4Nl4SXxG8W7JDG5DMJu97V25RnbNg3QtQtf+KD+VLjNpWKYsRvXDNmNrBgEETr1ifA==",
"license": "ISC",
- "peer": true,
"dependencies": {
"get-stream": "^6.0.1",
"minimist": "^1.2.6"
@@ -7027,6 +7031,12 @@
"license": "MIT",
"peer": true
},
+ "node_modules/array-source": {
+ "version": "0.0.4",
+ "resolved": "https://registry.npmjs.org/array-source/-/array-source-0.0.4.tgz",
+ "integrity": "sha512-frNdc+zBn80vipY+GdcJkLEbMWj3xmzArYApmUGxoiV8uAu/ygcs9icPdsGdA26h0MkHUMW6EN2piIvVx+M5Mw==",
+ "license": "BSD-3-Clause"
+ },
"node_modules/array-union": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/array-union/-/array-union-2.1.0.tgz",
@@ -11503,6 +11513,15 @@
"url": "https://opencollective.com/webpack"
}
},
+ "node_modules/file-source": {
+ "version": "0.6.1",
+ "resolved": "https://registry.npmjs.org/file-source/-/file-source-0.6.1.tgz",
+ "integrity": "sha512-1R1KneL7eTXmXfKxC10V/9NeGOdbsAXJ+lQ//fvvcHUgtaZcZDWNJNblxAoVOyV1cj45pOtUrR3vZTBwqcW8XA==",
+ "license": "BSD-3-Clause",
+ "dependencies": {
+ "stream-source": "0.3"
+ }
+ },
"node_modules/fill-range": {
"version": "7.1.1",
"resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz",
@@ -16809,6 +16828,12 @@
"node": ">= 0.6"
}
},
+ "node_modules/mgrs": {
+ "version": "1.0.0",
+ "resolved": "https://registry.npmjs.org/mgrs/-/mgrs-1.0.0.tgz",
+ "integrity": "sha512-awNbTOqCxK1DBGjalK3xqWIstBZgN6fxsMSiXLs9/spqWkF2pAhb2rrYCFSsr1/tT7PhcDGjZndG8SWYn0byYA==",
+ "license": "MIT"
+ },
"node_modules/micromatch": {
"version": "4.0.8",
"resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz",
@@ -17895,6 +17920,16 @@
"integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==",
"license": "MIT"
},
+ "node_modules/path-source": {
+ "version": "0.1.3",
+ "resolved": "https://registry.npmjs.org/path-source/-/path-source-0.1.3.tgz",
+ "integrity": "sha512-dWRHm5mIw5kw0cs3QZLNmpUWty48f5+5v9nWD2dw3Y0Hf+s01Ag8iJEWV0Sm0kocE8kK27DrIowha03e1YR+Qw==",
+ "license": "BSD-3-Clause",
+ "dependencies": {
+ "array-source": "0.0",
+ "file-source": "0.6"
+ }
+ },
"node_modules/path-to-regexp": {
"version": "6.3.0",
"resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-6.3.0.tgz",
@@ -18380,6 +18415,27 @@
"integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==",
"license": "MIT"
},
+ "node_modules/proj4": {
+ "version": "2.21.0",
+ "resolved": "https://registry.npmjs.org/proj4/-/proj4-2.21.0.tgz",
+ "integrity": "sha512-33HfDftqw8kY+Cl1dcL16SJuqTSzYxmz4re7Nmhk+fs1/N1fFrAkkF579msQTR/4fLeJVSNne8/gpoCz0fk1uw==",
+ "license": "MIT",
+ "dependencies": {
+ "mgrs": "1.0.0",
+ "wkt-parser": "^1.5.5"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/ahocevar"
+ },
+ "peerDependencies": {
+ "geotiff": "*"
+ },
+ "peerDependenciesMeta": {
+ "geotiff": {
+ "optional": true
+ }
+ }
+ },
"node_modules/promise": {
"version": "8.3.0",
"resolved": "https://registry.npmjs.org/promise/-/promise-8.3.0.tgz",
@@ -20399,6 +20455,30 @@
"license": "MIT",
"peer": true
},
+ "node_modules/shapefile": {
+ "version": "0.6.6",
+ "resolved": "https://registry.npmjs.org/shapefile/-/shapefile-0.6.6.tgz",
+ "integrity": "sha512-rLGSWeK2ufzCVx05wYd+xrWnOOdSV7xNUW5/XFgx3Bc02hBkpMlrd2F1dDII7/jhWzv0MSyBFh5uJIy9hLdfuw==",
+ "license": "BSD-3-Clause",
+ "dependencies": {
+ "array-source": "0.0",
+ "commander": "2",
+ "path-source": "0.1",
+ "slice-source": "0.4",
+ "stream-source": "0.3",
+ "text-encoding": "^0.6.4"
+ },
+ "bin": {
+ "dbf2json": "bin/dbf2json",
+ "shp2json": "bin/shp2json"
+ }
+ },
+ "node_modules/shapefile/node_modules/commander": {
+ "version": "2.20.3",
+ "resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz",
+ "integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==",
+ "license": "MIT"
+ },
"node_modules/shebang-command": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz",
@@ -20558,6 +20638,12 @@
"node": ">=8"
}
},
+ "node_modules/slice-source": {
+ "version": "0.4.1",
+ "resolved": "https://registry.npmjs.org/slice-source/-/slice-source-0.4.1.tgz",
+ "integrity": "sha512-YiuPbxpCj4hD9Qs06hGAz/OZhQ0eDuALN0lRWJez0eD/RevzKqGdUx1IOMUnXgpr+sXZLq3g8ERwbAH0bCb8vg==",
+ "license": "BSD-3-Clause"
+ },
"node_modules/sockjs": {
"version": "0.3.24",
"resolved": "https://registry.npmjs.org/sockjs/-/sockjs-0.3.24.tgz",
@@ -20769,6 +20855,12 @@
"license": "MIT",
"peer": true
},
+ "node_modules/stream-source": {
+ "version": "0.3.5",
+ "resolved": "https://registry.npmjs.org/stream-source/-/stream-source-0.3.5.tgz",
+ "integrity": "sha512-ZuEDP9sgjiAwUVoDModftG0JtYiLUV8K4ljYD1VyUMRWtbVf92474o4kuuul43iZ8t/hRuiDAx1dIJSvirrK/g==",
+ "license": "BSD-3-Clause"
+ },
"node_modules/strict-event-emitter": {
"version": "0.4.6",
"resolved": "https://registry.npmjs.org/strict-event-emitter/-/strict-event-emitter-0.4.6.tgz",
@@ -21462,6 +21554,13 @@
"node": ">=8"
}
},
+ "node_modules/text-encoding": {
+ "version": "0.6.4",
+ "resolved": "https://registry.npmjs.org/text-encoding/-/text-encoding-0.6.4.tgz",
+ "integrity": "sha512-hJnc6Qg3dWoOMkqP53F0dzRIgtmsAge09kxUIqGrEUS4qr5rWLckGYaQAVr+opBrIMRErGgy6f5aPnyPpyGRfg==",
+ "deprecated": "no longer maintained",
+ "license": "Unlicense"
+ },
"node_modules/text-segmentation": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/text-segmentation/-/text-segmentation-1.0.3.tgz",
@@ -23155,6 +23254,15 @@
"dev": true,
"license": "MIT"
},
+ "node_modules/wkt-parser": {
+ "version": "1.5.6",
+ "resolved": "https://registry.npmjs.org/wkt-parser/-/wkt-parser-1.5.6.tgz",
+ "integrity": "sha512-cqHU3lzGt/gt2OqIORP0uVy5yeOX43WABmgFkmJsmcqH3HhQo9PiJG6ftytwGKmpQUbegeX7+pQc2BuNCRfcrw==",
+ "license": "MIT",
+ "funding": {
+ "url": "https://github.com/sponsors/ahocevar"
+ }
+ },
"node_modules/word-wrap": {
"version": "1.2.5",
"resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz",
diff --git a/package.json b/package.json
index 198f2531..92fc0705 100644
--- a/package.json
+++ b/package.json
@@ -19,6 +19,7 @@
"author": "",
"license": "ISC",
"dependencies": {
+ "@mapbox/geojson-rewind": "0.5.2",
"@mapbox/vector-tile": "^1.3.1",
"@tiptap/extension-color": "^2.12.0",
"@tiptap/extension-font-family": "^2.12.0",
@@ -42,6 +43,7 @@
"date-fns": "^4.1.0",
"dompurify": "^3.1.6",
"dotenv": "^16.0.1",
+ "fflate": "0.8.2",
"file-loader": "^6.2.0",
"geotiff": "2.1.3",
"html-react-parser": "^5.1.18",
@@ -52,6 +54,7 @@
"ol-pmtiles": "^2.0.2",
"plotly.js-strict-dist-min": "^2.35.2",
"prismjs": "^1.28.0",
+ "proj4": "2.21.0",
"rc-slider": "^11.1.8",
"react": "^18.3.1",
"react-bootstrap": "^2.10.2",
@@ -72,13 +75,15 @@
"react-use-websocket": "^4.13.0",
"sass": "^1.49.0",
"sass-loader": "^12.3.0",
+ "shapefile": "0.6.6",
"simple-xml-to-json": "^1.2.3",
"style-loader": "^3.3.1",
"styled-components": "^6.3.9",
"swiper": "^11.2.1",
"uuid": "^8.3.2",
"webpack": "^5.64.4",
- "webpack-dev-server": "^4.6.0"
+ "webpack-dev-server": "^4.6.0",
+ "wkt-parser": "1.5.6"
},
"devDependencies": {
"@babel/core": "^7.18.2",
diff --git a/reactapp/__tests__/components/map/Map.test.js b/reactapp/__tests__/components/map/Map.test.js
index 837274d3..8dff0255 100644
--- a/reactapp/__tests__/components/map/Map.test.js
+++ b/reactapp/__tests__/components/map/Map.test.js
@@ -30,15 +30,20 @@ jest.mock("ol/source/GeoTIFF.js", () => {
}
getView() {
getViewSpy();
- return Promise.resolve({
- projection: "EPSG:4326",
- extent: [-180, -90, 180, 90],
- center: [0, 0],
- zoom: 2,
- });
+ // Overridable so a test can drive a raster whose projection resolves from
+ // a registered definition rather than natively.
+ return Promise.resolve(
+ MockGeoTIFFSource.viewOptions ?? {
+ projection: "EPSG:4326",
+ extent: [-180, -90, 180, 90],
+ center: [0, 0],
+ zoom: 2,
+ },
+ );
}
}
MockGeoTIFFSource.getViewSpy = getViewSpy;
+ MockGeoTIFFSource.viewOptions = null;
return {
__esModule: true,
default: MockGeoTIFFSource,
@@ -1509,6 +1514,87 @@ describe("WebGLTile ramp-style render path (Unit 7)", () => {
});
});
+ test("auto-fit does not adopt a registered-but-not-native projection as the view projection", async () => {
+ // EPSG:5041 resolves because the projection table registers it, which is
+ // what makes such a raster render at all. It must not also become the view
+ // projection: adoption calls setView and publishes the adopted code into the
+ // map-extent variable other visualizations read. Widening that is a separate
+ // change, so the view has to stay put while the layer still renders.
+ const warn = jest.spyOn(console, "warn").mockImplementation(() => {});
+ GeoTIFFSource.viewOptions = {
+ projection: "EPSG:5041",
+ extent: [-1405881, -1405881, 5405881, 5405881],
+ center: [2000000, 2000000],
+ zoom: 2,
+ };
+
+ let capturedRef;
+ const RefCapture = ({ mapProps }) => {
+ const ref = useRef();
+ capturedRef = ref;
+ return (
+
+
+
{useMapContext()?.mapReady ? "Map Ready" : "Map Not Ready"}
+
+ );
+ };
+ RefCapture.propTypes = { mapProps: PropTypes.object };
+
+ const layers = [
+ {
+ type: "WebGLTile",
+ props: {
+ source: {
+ type: "GeoTIFF",
+ props: { url: "https://example.com/polar.tif" },
+ },
+ name: "Polar GeoTIFF Layer",
+ zIndex: 0,
+ },
+ },
+ ];
+
+ try {
+ render(
+
+
+
+
+ ,
+ );
+
+ expect(await screen.findByText("Map Ready")).toBeInTheDocument();
+ await waitFor(() => {
+ expect(GeoTIFFSource.getViewSpy).toHaveBeenCalled();
+ });
+
+ // The layer is still added -- rendering by reprojection is the point.
+ await waitFor(() => {
+ const names = capturedRef.current
+ .getLayers()
+ .getArray()
+ .map((l) => l.get("name"));
+ expect(names).toContain("Polar GeoTIFF Layer");
+ });
+
+ // ...but the view did not move off the default.
+ expect(capturedRef.current.getView().getProjection().getCode()).toBe(
+ "EPSG:3857",
+ );
+ await waitFor(() => {
+ expect(warn).toHaveBeenCalledWith(
+ expect.stringContaining('Not adopting "EPSG:5041"'),
+ );
+ });
+ } finally {
+ GeoTIFFSource.viewOptions = null;
+ warn.mockRestore();
+ }
+ });
+
test("GeoTIFF layer triggers auto-fit: map view's projection switches to the TIF's", async () => {
// The auto-fit's contract is: when a GeoTIFF layer is added, the map's
// view projection switches to the TIF's so tiles can render. The mock
diff --git a/reactapp/__tests__/components/map/projections.test.js b/reactapp/__tests__/components/map/projections.test.js
new file mode 100644
index 00000000..560daeaa
--- /dev/null
+++ b/reactapp/__tests__/components/map/projections.test.js
@@ -0,0 +1,196 @@
+import proj4 from "proj4";
+import { get as getProjection } from "ol/proj.js";
+import {
+ PROJECTION_TABLE,
+ INITIAL_CODES,
+ ensureProjection,
+ registerProjectionFromWkt,
+} from "components/map/projections";
+
+// A projected CRS with no AUTHORITY node, which is how ESRI writes .prj files.
+const ESRI_ALBERS_NO_AUTHORITY = `PROJCS["NAD_1983_Albers",GEOGCS["GCS_North_American_1983",DATUM["D_North_American_1983",SPHEROID["GRS_1980",6378137.0,298.257222101]],PRIMEM["Greenwich",0.0],UNIT["Degree",0.0174532925199433]],PROJECTION["Albers"],PARAMETER["False_Easting",0.0],PARAMETER["False_Northing",0.0],PARAMETER["Central_Meridian",-96.0],PARAMETER["Standard_Parallel_1",29.5],PARAMETER["Standard_Parallel_2",45.5],PARAMETER["Latitude_Of_Origin",23.0],UNIT["Meter",1.0]]`;
+
+// Append an AUTHORITY node to the outermost PROJCS. It has to go before the
+// final bracket: a string replace on "]]" lands inside GEOGCS instead, which
+// wkt-parser then reports as a nested authority and the module correctly ignores
+// -- making any test built that way pass without testing anything.
+function withAuthority(wkt, code) {
+ const [name, id] = code.split(":");
+ return `${wkt.slice(0, -1)},AUTHORITY["${name}","${id}"]]`;
+}
+
+// The same body, claiming EPSG:5070 -- which the table already covers -- and
+// with a deliberately wrong standard parallel, to prove the seeded definition is
+// not replaced by a layer's copy.
+const ALBERS_5070_WRONG_PARAMS = withAuthority(
+ ESRI_ALBERS_NO_AUTHORITY.replace(
+ '"Standard_Parallel_1",29.5',
+ '"Standard_Parallel_1",20',
+ ),
+ "EPSG:5070",
+);
+
+// The OGC spelling of the same projection. proj4 does not implement this
+// variant: it yields non-finite coordinates even at the projection's own centre,
+// so registration reports it rather than rendering features nowhere.
+const OGC_ALBERS = `PROJCS["NAD83 / Conus Albers",GEOGCS["NAD83",DATUM["North_American_Datum_1983",SPHEROID["GRS 1980",6378137,298.257222101]],PRIMEM["Greenwich",0],UNIT["degree",0.0174532925199433]],PROJECTION["Albers_Conic_Equal_Area"],PARAMETER["latitude_of_center",23],PARAMETER["longitude_of_center",-96],PARAMETER["standard_parallel_1",29.5],PARAMETER["standard_parallel_2",45.5],UNIT["metre",1]]`;
+
+const UNSUPPORTED_METHOD = `PROJCS["nonsense",GEOGCS["g",DATUM["d",SPHEROID["s",6378137,298.257222101]],PRIMEM["Greenwich",0],UNIT["Degree",0.0174532925199433]],PROJECTION["Totally_Not_A_Real_Projection"],UNIT["Meter",1.0]]`;
+
+describe("projection table", () => {
+ it("resolves every code registered at init and gives it an extent", () => {
+ INITIAL_CODES.forEach((code) => {
+ const projection = getProjection(code);
+ expect(projection).toBeTruthy();
+ // register() cannot supply an extent, so a null here means the
+ // post-registration extent application did not run.
+ expect(projection.getExtent()).toEqual(PROJECTION_TABLE[code].extent);
+ });
+ });
+
+ // Cross-implementation check: the expected values come from PROJ's EPSG
+ // database, and the control points sit away from each projection's origin so a
+ // wrong standard parallel, scale factor or linear unit moves the result. A
+ // point at the origin would return the false easting regardless.
+ it.each(INITIAL_CODES)(
+ "%s transforms its control point to the coordinate PROJ gives",
+ (code) => {
+ const { lonLat, projected } = PROJECTION_TABLE[code].controlPoint;
+ const [x, y] = proj4("EPSG:4326", code, lonLat);
+ expect(x).toBeCloseTo(projected[0], 2);
+ expect(y).toBeCloseTo(projected[1], 2);
+ },
+ );
+
+ it("registers a bounded number of codes at init rather than the whole table", () => {
+ // register() is quadratic in registered-definition count, and this module is
+ // a static import, so an unbounded init set is a first-render regression on
+ // every dashboard.
+ expect(INITIAL_CODES.length).toBeLessThanOrEqual(4);
+ });
+
+ it("leaves the native projections untouched", () => {
+ expect(getProjection("EPSG:4326").getUnits()).toBe("degrees");
+ expect(getProjection("EPSG:3857").getUnits()).toBe("m");
+ expect(getProjection("EPSG:3857").getExtent()).toBeTruthy();
+ // OpenLayers' own UTM factory still answers for zone codes.
+ expect(getProjection("EPSG:32615")).toBeTruthy();
+ });
+});
+
+describe("ensureProjection", () => {
+ it("returns null for a code that is neither native nor in the table", () => {
+ expect(ensureProjection("EPSG:99999")).toBeNull();
+ });
+
+ it("returns null for an empty code without throwing", () => {
+ expect(ensureProjection(undefined)).toBeNull();
+ expect(ensureProjection("")).toBeNull();
+ });
+
+ it("resolves a native code without needing a table entry", () => {
+ expect(ensureProjection("EPSG:4326")).toBeTruthy();
+ });
+
+ it("registers a table entry on demand and applies its extent", () => {
+ const projection = ensureProjection("EPSG:5070");
+ expect(projection).toBeTruthy();
+ expect(projection.getExtent()).toEqual(
+ PROJECTION_TABLE["EPSG:5070"].extent,
+ );
+ });
+});
+
+describe("registerProjectionFromWkt", () => {
+ it("registers WKT with no authority node and transforms with it", () => {
+ const result = registerProjectionFromWkt(ESRI_ALBERS_NO_AUTHORITY);
+ expect(result.error).toBeUndefined();
+ expect(result.code).toMatch(/^WKT:/);
+ expect(getProjection(result.code)).toBeTruthy();
+
+ // Assert the resolved parameters, not merely that nothing threw: proj4 has a
+ // history of parsing ESRI WKT variants and silently producing wrong ones.
+ const [x, y] = proj4("EPSG:4326", result.code, [-105, 40]);
+ expect(x).toBeCloseTo(-760465.745, 2);
+ expect(y).toBeCloseTo(1923013.98, 2);
+ });
+
+ it("reuses an already-resolvable code instead of registering the layer's copy", () => {
+ const before = proj4("EPSG:4326", "EPSG:5070", [-105, 40]);
+
+ const result = registerProjectionFromWkt(ALBERS_5070_WRONG_PARAMS);
+ expect(result.code).toBe("EPSG:5070");
+
+ // The seeded definition survives and the layer's differing parameters are
+ // discarded. Without this, one shapefile changes how every other layer on
+ // every dashboard in this session transforms.
+ const after = proj4("EPSG:4326", "EPSG:5070", [-105, 40]);
+ expect(after[0]).toBeCloseTo(before[0], 6);
+ expect(after[1]).toBeCloseTo(before[1], 6);
+ });
+
+ it("never registers under a claimed authority code", () => {
+ const claimed = "EPSG:26985";
+ expect(getProjection(claimed)).toBeFalsy();
+ const wkt = withAuthority(ESRI_ALBERS_NO_AUTHORITY, claimed);
+
+ const result = registerProjectionFromWkt(wkt);
+
+ // Guard against this passing vacuously: the fixture must actually carry a
+ // top-level authority, or the module would fall through to a synthetic code
+ // for the wrong reason and the assertions below would prove nothing.
+ expect(wkt).toContain('AUTHORITY["EPSG","26985"]]');
+ expect(result.code).toMatch(/^WKT:/);
+ expect(result.code).not.toBe(claimed);
+ // The claimed code stays unresolvable, so a later layer naming it by code
+ // does not silently inherit this layer's parameters.
+ expect(getProjection(claimed)).toBeFalsy();
+ });
+
+ it("returns the same code for the same WKT without re-registering", () => {
+ const first = registerProjectionFromWkt(ESRI_ALBERS_NO_AUTHORITY);
+ const second = registerProjectionFromWkt(ESRI_ALBERS_NO_AUTHORITY);
+ expect(second.code).toBe(first.code);
+ });
+
+ it("reports an unparsable definition and names what failed", () => {
+ const result = registerProjectionFromWkt('PROJCS["broken",GARBAGE[');
+ expect(result.code).toBeUndefined();
+ expect(result.error.reason).toBe("unparsable");
+ expect(result.error.detail).toMatch(/could not be parsed/);
+ });
+
+ it("reports an unsupported projection method by name", () => {
+ // This one parses cleanly and only fails at transform time, so the message
+ // has to come from validating the transform rather than from the parser.
+ const result = registerProjectionFromWkt(UNSUPPORTED_METHOD);
+ expect(result.code).toBeUndefined();
+ expect(result.error.reason).toBe("unsupported");
+ expect(result.error.detail).toContain("Totally_Not_A_Real_Projection");
+ });
+
+ it("reports the OGC Albers variant as unsupported rather than rendering nowhere", () => {
+ // proj4 implements the ESRI spelling of Albers but not this one, and the
+ // failure is silent: non-finite coordinates, not an exception. Caught by
+ // probing the definition at its own centre before registering it.
+ const result = registerProjectionFromWkt(OGC_ALBERS);
+ expect(result.code).toBeUndefined();
+ expect(result.error.reason).toBe("unsupported");
+ expect(result.error.detail).toContain("Albers_Conic_Equal_Area");
+ });
+
+ it("leaves the registry usable after rejecting a definition", () => {
+ // A rejected definition must not stay in proj4's registry: register()
+ // constructs a transform for every pair of registered codes, so one unusable
+ // definition would make it throw and take working projections down with it.
+ registerProjectionFromWkt(UNSUPPORTED_METHOD);
+ const after = registerProjectionFromWkt(ESRI_ALBERS_NO_AUTHORITY);
+ expect(after.error).toBeUndefined();
+ expect(getProjection("EPSG:5070")).toBeTruthy();
+ });
+
+ it("reports an empty definition rather than throwing", () => {
+ expect(registerProjectionFromWkt("").error.reason).toBe("empty");
+ expect(registerProjectionFromWkt(undefined).error.reason).toBe("empty");
+ });
+});
diff --git a/reactapp/components/map/Map.js b/reactapp/components/map/Map.js
index 9d0cebf7..a374c48d 100644
--- a/reactapp/components/map/Map.js
+++ b/reactapp/components/map/Map.js
@@ -4,6 +4,12 @@ import moduleLoader, {
applyAutoRamp,
createJsonStyleFunction,
} from "components/map/ModuleLoader";
+// Importing this registers the coordinate reference systems that layers name by
+// code. Module evaluation completes before any render, so registration is in
+// place before the layer effect below constructs a single source -- which
+// matters, because layers are constructed concurrently and a registration that
+// waited on anything async would race them.
+import { isNativelyResolvable } from "components/map/projections";
import LayersControl from "components/map/LayersControl";
import FloatingMapControl from "components/map/FloatingMapControl";
import LegendControl from "components/map/LegendControl";
@@ -546,8 +552,24 @@ const MapComponent = ({
// feature count. Move them with the view.
const previousCode = prevProjection.getCode();
const adoptedCode = newView.getProjection().getCode();
- map.setView(newView);
- reprojectVectorFeatures(map, previousCode, adoptedCode);
+
+ // Adopt the raster's projection as the view projection only when
+ // OpenLayers resolves it on its own. Registering a definition
+ // makes a previously-unresolvable raster render, but it must not
+ // also start changing the view: setView publishes the adopted
+ // code into the map-extent variable other visualizations consume,
+ // and saved center/zoom values would be reinterpreted in the new
+ // projection's units. Widening this is its own change, verified
+ // against live dashboards. Such a raster still renders here --
+ // by reprojection rather than natively.
+ if (!isNativelyResolvable(adoptedCode)) {
+ console.warn(
+ `Not adopting "${adoptedCode}" as the view projection for layer "${name}": it resolves from a registered definition rather than natively. The layer renders by reprojection.`,
+ );
+ } else {
+ map.setView(newView);
+ reprojectVectorFeatures(map, previousCode, adoptedCode);
+ }
} catch (err) {
console.warn(
`GeoTIFF auto-fit failed for layer "${name}":`,
diff --git a/reactapp/components/map/projections.js b/reactapp/components/map/projections.js
new file mode 100644
index 00000000..569e309e
--- /dev/null
+++ b/reactapp/components/map/projections.js
@@ -0,0 +1,264 @@
+import proj4 from "proj4";
+import { register } from "ol/proj/proj4.js";
+import { get as getProjection } from "ol/proj.js";
+import wktParser from "wkt-parser";
+
+// Coordinate reference systems the map can resolve, beyond the ones OpenLayers
+// ships with. OL natively handles EPSG:4326, EPSG:3857 and every WGS84 UTM zone
+// via its own projection factory; everything else -- State Plane, Albers, the
+// polar stereographics -- resolves only if a definition is registered here.
+//
+// Two things are registered, from two different places, and the split matters:
+//
+// 1. Codes named by a layer that carries no definition of its own. A WMS or
+// GeoTIFF layer says "EPSG:5041" and nothing more, so the definition has to
+// already be on hand. That is what the table below is for.
+//
+// 2. Definitions a layer brings with it. A shapefile carries its CRS as WKT in
+// its .prj, so it needs no table entry -- see registerProjectionFromWkt.
+//
+// The table therefore only has to cover case 1, which is why it is short. A
+// survey of the live dashboards found exactly one layer naming a non-native code
+// (a WMS layer requesting EPSG:5041); EPSG:5070 is included because US national
+// hydrology datasets commonly name Conus Albers by code. Adding a zone is a
+// table entry plus a control point, and `ensureProjection` registers it on
+// demand rather than at startup.
+//
+// Registration is deliberately *not* done for the whole table at load time.
+// `register` builds pairwise transforms across every registered code, so its
+// cost is quadratic: measured at 99ms for two definitions on top of proj4's
+// built-ins, and hundreds of milliseconds once a State-Plane-sized set is in
+// play. This module is imported statically by the map, so that cost would land
+// before first render on every dashboard, including the ones with no layer that
+// needs it.
+
+// Definitions, extents and control points are taken from PROJ's EPSG database
+// rather than hand-derived. The control points sit away from each projection's
+// origin so they exercise the standard parallels and scale factor -- a point at
+// the origin would return the false easting no matter how wrong the rest of the
+// definition was. proj4 agrees with PROJ on both to sub-millimetre, so the test
+// that round-trips them is a cross-implementation check, not a self-consistency
+// one.
+export const PROJECTION_TABLE = {
+ "EPSG:5041": {
+ name: "WGS 84 / UPS North (E,N)",
+ definition:
+ "+proj=stere +lat_0=90 +lon_0=0 +k=0.994 +x_0=2000000 +y_0=2000000 +datum=WGS84 +units=m +no_defs",
+ extent: [-1405881, -1405881, 5405881, 5405881],
+ controlPoint: { lonLat: [-45, 70], projected: [414390.988, 414390.988] },
+ },
+ "EPSG:5070": {
+ name: "NAD83 / Conus Albers",
+ definition:
+ "+proj=aea +lat_0=23 +lon_0=-96 +lat_1=29.5 +lat_2=45.5 +x_0=0 +y_0=0 +datum=NAD83 +units=m +no_defs",
+ extent: [-2916311, 153629, 2945750, 3255275],
+ controlPoint: { lonLat: [-105, 40], projected: [-760465.745, 1923013.98] },
+ },
+};
+
+// Registered when this module is evaluated. Keep this list to codes a layer
+// actually names today; the rest of the table is reachable through
+// `ensureProjection` at the point of use.
+export const INITIAL_CODES = ["EPSG:5041", "EPSG:5070"];
+
+// Prefix for projections registered from a layer's own WKT. Kept distinct from
+// any authority namespace so a synthetic code can never be mistaken for -- or
+// collide with -- a real EPSG code.
+const WKT_CODE_PREFIX = "WKT:";
+
+/**
+ * Whether OpenLayers resolves this code on its own, without anything registered
+ * here.
+ *
+ * Asked by code rather than by registry lookup, because once a definition is
+ * registered the two are indistinguishable through the registry -- which is the
+ * whole point of the question. Used to keep the raster auto-fit from adopting a
+ * newly-registered projection as the map's *view* projection: adoption calls
+ * setView and publishes the adopted code into the map-extent variable other
+ * visualizations read, so widening it is a separate change with its own
+ * verification. Registered projections still serve as data projections, so a
+ * raster in one renders by reprojection instead.
+ *
+ * @param {string} code Projection code.
+ * @returns {boolean}
+ */
+export function isNativelyResolvable(code) {
+ if (typeof code !== "string") return false;
+ const match = /^EPSG:(\d+)$/.exec(code.trim());
+ if (!match) return false;
+ const id = Number(match[1]);
+ if ([4326, 3857, 900913, 102100].includes(id)) return true;
+ // OpenLayers ships a UTM projection factory covering the WGS84 zones.
+ return (id > 32600 && id < 32661) || (id > 32700 && id < 32761);
+}
+
+// Registering a definition does not give the resulting projection an extent:
+// `register` builds it from the proj4 definition, and a proj4 definition has
+// nowhere to carry one. Verified against the installed versions -- the extent
+// reads null until it is set explicitly. OpenLayers uses projection extent for
+// view clamping, so a projection that ever becomes the view projection without
+// one degrades silently.
+function applyExtent(code) {
+ const entry = PROJECTION_TABLE[code];
+ const projection = getProjection(code);
+ if (entry?.extent && projection && !projection.getExtent()) {
+ projection.setExtent(entry.extent);
+ }
+}
+
+// Definitions have to be declared and registered together. `register` iterates
+// everything already in proj4's registry, so declaring the whole table and then
+// registering a subset is not possible -- the subset is chosen by what gets
+// declared.
+function registerCodes(codes) {
+ const pending = codes.filter(
+ (code) => PROJECTION_TABLE[code] && !getProjection(code),
+ );
+ if (pending.length === 0) return;
+ pending.forEach((code) => {
+ proj4.defs(code, PROJECTION_TABLE[code].definition);
+ });
+ register(proj4);
+ pending.forEach(applyExtent);
+}
+
+/**
+ * Resolve a projection by code, registering its table entry if it has not been
+ * registered yet.
+ *
+ * Re-registering is safe: OpenLayers skips any code already in its projection
+ * cache, so previously registered projections keep their identity and their
+ * applied extent.
+ *
+ * @param {string} code Projection code, e.g. "EPSG:5070".
+ * @returns {import("ol/proj/Projection.js").default|null} The projection, or
+ * null when the code is neither native nor in the table.
+ */
+export function ensureProjection(code) {
+ if (!code) return null;
+ const existing = getProjection(code);
+ if (existing) return existing;
+ if (!PROJECTION_TABLE[code]) return null;
+ registerCodes([code]);
+ return getProjection(code);
+}
+
+// Stable, dependency-free hash of the normalized WKT. Two textually different
+// but semantically equivalent definitions hash differently and so register
+// separately; that costs a duplicate registration and nothing else, which is
+// cheaper than trying to canonicalise WKT.
+function wktCode(wkt) {
+ const normalized = wkt.replace(/\s+/g, "");
+ let hash = 5381;
+ for (let i = 0; i < normalized.length; i += 1) {
+ hash = ((hash << 5) + hash + normalized.charCodeAt(i)) | 0;
+ }
+ return `${WKT_CODE_PREFIX}${(hash >>> 0).toString(36)}`;
+}
+
+// The outermost AUTHORITY node, which is the one belonging to the projected CRS
+// itself. A WKT string carries several -- the datum and the geographic CRS have
+// their own -- so reading the last one out of the raw text would pick the wrong
+// node. wkt-parser hands back only the top-level one.
+function claimedCode(parsed) {
+ const authority = parsed?.AUTHORITY;
+ if (!authority) return null;
+ const [name] = Object.keys(authority);
+ if (!name) return null;
+ return `${name}:${authority[name]}`;
+}
+
+// Where to probe a candidate definition. It has to be a point the projection
+// actually covers: an Albers centred on -96 returns nothing usable at [0, 0], so
+// probing there would report a perfectly good definition as unsupported. The
+// parsed definition carries its own centre in radians, which is always inside
+// the domain.
+function probePoint(parsed) {
+ const toDegrees = 180 / Math.PI;
+ const lon = parsed?.long0 ?? parsed?.longc;
+ const lat = parsed?.lat0 ?? parsed?.lat_ts;
+ return [
+ typeof lon === "number" ? lon * toDegrees : 0,
+ typeof lat === "number" ? lat * toDegrees : 0,
+ ];
+}
+
+// A WKT whose projection method proj4 does not implement parses cleanly, and
+// declaring it does not fail either -- it only goes wrong at transform time, and
+// then with an internal error that names nothing. The only way to tell is to
+// transform something, which is why every candidate is probed.
+//
+// This runs before `register`, deliberately. `register` constructs a transform
+// for every pair of registered codes, so an unusable definition sitting in the
+// registry makes it throw -- taking down projections that were working. A
+// candidate that fails is removed again before anything else sees it.
+function definitionUsable(code, parsed) {
+ try {
+ const [x, y] = proj4("EPSG:4326", code, probePoint(parsed));
+ return Number.isFinite(x) && Number.isFinite(y);
+ } catch {
+ return false;
+ }
+}
+
+/**
+ * Register a coordinate reference system from a layer's own WKT definition.
+ *
+ * Never overwrites a definition that already resolves. A layer's WKT is
+ * authoritative for that layer's own features, but the projection registry is
+ * global to the browser session -- so letting one layer's parameters replace a
+ * code every other layer resolves through would make rendering depend on which
+ * dashboard was opened first. When the WKT claims a code that already resolves,
+ * the existing definition is reused and nothing is written. Otherwise the
+ * definition is registered under a synthetic code, never under the claimed one.
+ *
+ * @param {string} wkt WKT definition, typically the contents of a .prj.
+ * @returns {{code: string}|{error: {reason: string, detail: string}}} The code to
+ * read coordinates with, or a failure describing what could not be resolved.
+ */
+export function registerProjectionFromWkt(wkt) {
+ if (typeof wkt !== "string" || wkt.trim() === "") {
+ return {
+ error: { reason: "empty", detail: "No projection definition was found." },
+ };
+ }
+
+ let parsed;
+ try {
+ parsed = wktParser(wkt);
+ } catch (error) {
+ return {
+ error: {
+ reason: "unparsable",
+ detail: `The projection definition could not be parsed: ${error.message}`,
+ },
+ };
+ }
+
+ const claimed = claimedCode(parsed);
+ if (claimed && (getProjection(claimed) || ensureProjection(claimed))) {
+ return { code: claimed };
+ }
+
+ const code = wktCode(wkt);
+ if (getProjection(code)) return { code };
+
+ proj4.defs(code, wkt);
+ if (!definitionUsable(code, parsed)) {
+ delete proj4.defs[code];
+ const method = parsed?.projName ?? "an unnamed projection method";
+ return {
+ error: {
+ reason: "unsupported",
+ detail: `The projection "${method}"${
+ claimed ? ` (${claimed})` : ""
+ } could not be resolved.`,
+ },
+ };
+ }
+
+ register(proj4);
+ return { code };
+}
+
+registerCodes(INITIAL_CODES);
From c1cee018a161c6201d05cac99f892677dbdf8edd Mon Sep 17 00:00:00 2001
From: Corey Krewson
Date: Mon, 24 Aug 2026 15:13:41 -0700
Subject: [PATCH 02/18] feat(map): fetch and decompress shapefile components
under a size ceiling
A map-free acquisition step: URL validation, sibling derivation, fetching,
decompression and a bounded component cache. It returns raw buffers and knows
nothing about what a shapefile means, so the parser choice -- which carries
the real risk here -- can change without disturbing any of it.
The size ceiling binds on each member's *declared* size, read from the local
header before any data flows, and refuses by never starting that member.
Summing bytes as they arrive does not work: an 8 MiB expansion arrives in a
single callback, so a running total only notices once the payload is already
allocated and inflated, which is the cost the ceiling exists to prevent. A
member declaring no size falls back to counting. Only shapefile components
are ever started, so a bomb parked in an unrelated member costs nothing --
covered by a test.
fflate's Unzip carries only a pass-through decoder, so without registering
the inflate decoder every member of a real archive throws on start. That is
a total failure rather than a degradation, and it now has a regression test.
Bodies are read whole rather than streamed. Aborting rejects the read and
terminates the transfer, which is what cancellation needs, and the configured
test environment exposes no response stream at all -- so a stream-reader
implementation could not have been exercised.
Absence and failure stay distinguishable on the sibling path: a 404 on an
optional component means absent, any other status is reported. A transient
403 routed into the absent path would fall back to the author-supplied
projection and draw features somewhere else entirely, with no error.
Two failure modes get accurate messages rather than misleading ones. A
response is checked for markup, and the buffer for the zip magic number,
because a portal returning an HTML error page with a 200 would otherwise be
reported as an archive containing no .shp entry. And a fetch-stage failure
names cross-origin policy, an unreachable host, a missing file and an expired
signature together, since a browser cannot tell them apart.
The cache holds component buffers keyed on resolved URL, not parsed features:
buffers are already under the ceiling by construction so a small entry count
has an exact memory bound, while parsed GeoJSON runs several times the
archive size. A hit skips the network hop and the decompression and still
re-parses. Failures are never cached, so a retry retries.
Co-Authored-By: Claude Opus 5 (1M context)
---
.../components/map/shapefile/acquire.test.js | 316 ++++++++++++++++++
.../components/map/shapefile/siblings.test.js | 101 ++++++
.../components/map/shapefile/unzip.test.js | 192 +++++++++++
reactapp/components/map/shapefile/acquire.js | 200 +++++++++++
reactapp/components/map/shapefile/cache.js | 64 ++++
reactapp/components/map/shapefile/siblings.js | 99 ++++++
reactapp/components/map/shapefile/unzip.js | 194 +++++++++++
7 files changed, 1166 insertions(+)
create mode 100644 reactapp/__tests__/components/map/shapefile/acquire.test.js
create mode 100644 reactapp/__tests__/components/map/shapefile/siblings.test.js
create mode 100644 reactapp/__tests__/components/map/shapefile/unzip.test.js
create mode 100644 reactapp/components/map/shapefile/acquire.js
create mode 100644 reactapp/components/map/shapefile/cache.js
create mode 100644 reactapp/components/map/shapefile/siblings.js
create mode 100644 reactapp/components/map/shapefile/unzip.js
diff --git a/reactapp/__tests__/components/map/shapefile/acquire.test.js b/reactapp/__tests__/components/map/shapefile/acquire.test.js
new file mode 100644
index 00000000..da580371
--- /dev/null
+++ b/reactapp/__tests__/components/map/shapefile/acquire.test.js
@@ -0,0 +1,316 @@
+import { zipSync, strToU8 } from "fflate";
+import {
+ acquireComponents,
+ DEFAULT_MAX_BYTES,
+} from "components/map/shapefile/acquire";
+import {
+ clearComponentCache,
+ cachedComponentCount,
+ CACHE_MAX_ENTRIES,
+} from "components/map/shapefile/cache";
+
+const MB = 1024 * 1024;
+const ARCHIVE = zipSync({
+ "basins.shp": strToU8("SHPBODY"),
+ "basins.dbf": strToU8("DBFBODY"),
+ "basins.prj": strToU8('PROJCS["NAD_1983_Albers"]'),
+ "basins.shx": strToU8("SHXBODY"),
+});
+
+// Minimal Response stand-in. The configured environment exposes no response
+// stream, so the implementation reads whole bodies and only arrayBuffer is
+// needed here.
+function respond({ status = 200, contentType = "application/zip", body } = {}) {
+ return {
+ ok: status >= 200 && status < 300,
+ status,
+ headers: { get: (name) => (name === "content-type" ? contentType : null) },
+ arrayBuffer: async () => (body ?? new Uint8Array()).buffer,
+ };
+}
+
+let fetchMock;
+
+beforeEach(() => {
+ clearComponentCache();
+ fetchMock = jest.fn();
+ global.fetch = fetchMock;
+});
+
+describe("acquireComponents — validation happens before any request", () => {
+ it.each([
+ "data:application/zip;base64,UEsDBA==",
+ "blob:https://example.org/8f3c",
+ "file:///tmp/basins.zip",
+ ])("rejects %s without fetching", async (url) => {
+ const result = await acquireComponents(url);
+ expect(result.error.reason).toBe("unsupported_scheme");
+ expect(fetchMock).not.toHaveBeenCalled();
+ });
+
+ it("rejects an unsupported path without fetching", async () => {
+ const result = await acquireComponents(
+ "https://example.org/basins.geojson",
+ );
+ expect(result.error.reason).toBe("unsupported_path");
+ expect(fetchMock).not.toHaveBeenCalled();
+ });
+});
+
+describe("acquireComponents — archive form", () => {
+ it("returns the four components from one request", async () => {
+ fetchMock.mockResolvedValue(respond({ body: ARCHIVE }));
+
+ const result = await acquireComponents("https://example.org/basins.zip");
+
+ expect(result.error).toBeUndefined();
+ expect(Object.keys(result.components).sort()).toEqual([
+ "dbf",
+ "prj",
+ "shp",
+ "shx",
+ ]);
+ expect(fetchMock).toHaveBeenCalledTimes(1);
+ });
+
+ it("reports a markup response as the wrong content type, naming what came back", async () => {
+ fetchMock.mockResolvedValue(
+ respond({
+ contentType: "text/html; charset=utf-8",
+ body: strToU8(""),
+ }),
+ );
+
+ const result = await acquireComponents("https://example.org/basins.zip");
+
+ expect(result.error.stage).toBe("parse");
+ expect(result.error.reason).toBe("wrong_content_type");
+ expect(result.error.detail).toContain("text/html");
+ });
+
+ it("reports a non-success status and names the candidate causes together", async () => {
+ fetchMock.mockResolvedValue(respond({ status: 403 }));
+
+ const result = await acquireComponents("https://example.org/basins.zip");
+
+ expect(result.error.stage).toBe("fetch");
+ expect(result.error.status).toBe(403);
+ // A browser cannot distinguish these, so the message must not claim one.
+ expect(result.error.detail).toMatch(/cross-origin/i);
+ expect(result.error.detail).toMatch(/expired signature/i);
+ });
+
+ it("reports a network rejection as unreachable rather than throwing", async () => {
+ fetchMock.mockRejectedValue(new TypeError("Failed to fetch"));
+
+ const result = await acquireComponents("https://example.org/basins.zip");
+
+ expect(result.error.reason).toBe("unreachable");
+ expect(result.error.detail).toMatch(/cross-origin/i);
+ });
+
+ it("refuses an archive that expands past the ceiling", async () => {
+ const bomb = zipSync({
+ "basins.shp": new Uint8Array(8 * MB),
+ "basins.prj": strToU8('PROJCS["x"]'),
+ });
+ fetchMock.mockResolvedValue(respond({ body: bomb }));
+
+ const result = await acquireComponents("https://example.org/basins.zip", {
+ maxBytes: 1 * MB,
+ });
+
+ expect(result.error.reason).toBe("too_large");
+ expect(result.error.permitted).toBe(1 * MB);
+ });
+});
+
+describe("acquireComponents — sibling form", () => {
+ function siblingResponder(overrides = {}) {
+ return (url) => {
+ const extension = url.split("?")[0].split(".").pop();
+ if (overrides[extension]) return Promise.resolve(overrides[extension]);
+ return Promise.resolve(
+ respond({
+ contentType: "application/octet-stream",
+ body: strToU8(`${extension.toUpperCase()}BODY`),
+ }),
+ );
+ };
+ }
+
+ it("derives and fetches every component", async () => {
+ fetchMock.mockImplementation(siblingResponder());
+
+ const result = await acquireComponents("https://example.org/basins.shp");
+
+ expect(result.error).toBeUndefined();
+ expect(Object.keys(result.components).sort()).toEqual([
+ "dbf",
+ "prj",
+ "shp",
+ "shx",
+ ]);
+ const requested = fetchMock.mock.calls.map(([u]) => u);
+ expect(requested).toEqual([
+ "https://example.org/basins.shp",
+ "https://example.org/basins.dbf",
+ "https://example.org/basins.prj",
+ "https://example.org/basins.shx",
+ ]);
+ });
+
+ it("preserves a signed query string on every derived request", async () => {
+ fetchMock.mockImplementation(siblingResponder());
+
+ await acquireComponents(
+ "https://bucket.s3.amazonaws.com/basins.shp?X-Amz-Signature=abc",
+ );
+
+ fetchMock.mock.calls.forEach(([url]) => {
+ expect(url).toContain("X-Amz-Signature=abc");
+ });
+ });
+
+ it("treats a 404 on an optional component as absent", async () => {
+ fetchMock.mockImplementation(
+ siblingResponder({ dbf: respond({ status: 404 }) }),
+ );
+
+ const result = await acquireComponents("https://example.org/basins.shp");
+
+ expect(result.error).toBeUndefined();
+ expect(result.components.dbf).toBeUndefined();
+ expect(result.components.shp).toBeTruthy();
+ });
+
+ it("treats any other status on an optional component as a reported failure", async () => {
+ // This is the distinction that matters: a transient 403 routed into the
+ // absent path would fall back to the author-supplied projection and draw the
+ // features somewhere else entirely, with no error.
+ fetchMock.mockImplementation(
+ siblingResponder({ prj: respond({ status: 403 }) }),
+ );
+
+ const result = await acquireComponents("https://example.org/basins.shp");
+
+ expect(result.error.reason).toBe("component_status");
+ expect(result.error.component).toBe("prj");
+ expect(result.error.status).toBe(403);
+ });
+
+ it("reports a missing .shp rather than continuing", async () => {
+ fetchMock.mockImplementation(
+ siblingResponder({ shp: respond({ status: 404 }) }),
+ );
+
+ const result = await acquireComponents("https://example.org/basins.shp");
+
+ expect(result.error.stage).toBe("fetch");
+ expect(result.error.status).toBe(404);
+ // Stops at the first component rather than paying for the other three.
+ expect(fetchMock).toHaveBeenCalledTimes(1);
+ });
+
+ it("refuses once the components total past the ceiling", async () => {
+ const half = new Uint8Array(600 * 1024);
+ fetchMock.mockImplementation(
+ siblingResponder({
+ shp: respond({ body: half }),
+ dbf: respond({ body: half }),
+ }),
+ );
+
+ const result = await acquireComponents("https://example.org/basins.shp", {
+ maxBytes: 1 * MB,
+ });
+
+ expect(result.error.reason).toBe("too_large");
+ });
+});
+
+describe("acquireComponents — cancellation", () => {
+ it("resolves as cancelled when the signal is already aborted", async () => {
+ const controller = new AbortController();
+ controller.abort();
+
+ const result = await acquireComponents("https://example.org/basins.zip", {
+ signal: controller.signal,
+ });
+
+ expect(result.cancelled).toBe(true);
+ expect(fetchMock).not.toHaveBeenCalled();
+ });
+
+ it("resolves as cancelled with no failure message when the body read aborts", async () => {
+ const controller = new AbortController();
+ fetchMock.mockImplementation(() => {
+ controller.abort();
+ const error = new Error("aborted");
+ error.name = "AbortError";
+ return Promise.reject(error);
+ });
+
+ const result = await acquireComponents("https://example.org/basins.zip", {
+ signal: controller.signal,
+ });
+
+ expect(result.cancelled).toBe(true);
+ expect(result.error).toBeUndefined();
+ });
+});
+
+describe("acquireComponents — caching", () => {
+ it("serves a repeat of the same resolved URL without fetching", async () => {
+ fetchMock.mockResolvedValue(respond({ body: ARCHIVE }));
+
+ const first = await acquireComponents("https://example.org/basins.zip");
+ const second = await acquireComponents("https://example.org/basins.zip");
+
+ expect(first.fromCache).toBeUndefined();
+ expect(second.fromCache).toBe(true);
+ expect(second.components.shp).toEqual(first.components.shp);
+ expect(fetchMock).toHaveBeenCalledTimes(1);
+ });
+
+ it("fetches a different resolved URL", async () => {
+ fetchMock.mockResolvedValue(respond({ body: ARCHIVE }));
+
+ await acquireComponents("https://example.org/a.zip");
+ await acquireComponents("https://example.org/b.zip");
+
+ expect(fetchMock).toHaveBeenCalledTimes(2);
+ });
+
+ it("evicts the least recently used entry once full", async () => {
+ fetchMock.mockResolvedValue(respond({ body: ARCHIVE }));
+
+ for (let i = 0; i <= CACHE_MAX_ENTRIES; i += 1) {
+ await acquireComponents(`https://example.org/${i}.zip`);
+ }
+ expect(cachedComponentCount()).toBe(CACHE_MAX_ENTRIES);
+
+ // The first URL was evicted, so it fetches again.
+ fetchMock.mockClear();
+ await acquireComponents("https://example.org/0.zip");
+ expect(fetchMock).toHaveBeenCalledTimes(1);
+ });
+
+ it("does not cache a failure, so a retry retries", async () => {
+ fetchMock.mockResolvedValueOnce(respond({ status: 503 }));
+ const failed = await acquireComponents("https://example.org/basins.zip");
+ expect(failed.error).toBeTruthy();
+
+ fetchMock.mockResolvedValueOnce(respond({ body: ARCHIVE }));
+ const retried = await acquireComponents("https://example.org/basins.zip");
+
+ expect(retried.error).toBeUndefined();
+ expect(fetchMock).toHaveBeenCalledTimes(2);
+ });
+});
+
+describe("acquireComponents — defaults", () => {
+ it("defaults the ceiling to 25 MB", () => {
+ expect(DEFAULT_MAX_BYTES).toBe(25 * MB);
+ });
+});
diff --git a/reactapp/__tests__/components/map/shapefile/siblings.test.js b/reactapp/__tests__/components/map/shapefile/siblings.test.js
new file mode 100644
index 00000000..3e515bc9
--- /dev/null
+++ b/reactapp/__tests__/components/map/shapefile/siblings.test.js
@@ -0,0 +1,101 @@
+import {
+ validateSourceUrl,
+ deriveSiblingUrls,
+} from "components/map/shapefile/siblings";
+
+describe("validateSourceUrl", () => {
+ it.each([
+ ["https://example.org/data/basins.zip", "archive"],
+ ["http://example.org/data/basins.ZIP", "archive"],
+ ["https://example.org/data/basins.shp", "components"],
+ ["https://example.org/data/basins.SHP", "components"],
+ ])("accepts %s as %s", (url, form) => {
+ expect(validateSourceUrl(url)).toEqual({ form, url });
+ });
+
+ it.each([
+ "data:application/zip;base64,UEsDBA==",
+ "blob:https://example.org/8f3c",
+ "file:///home/user/basins.zip",
+ "//example.org/basins.zip",
+ "ftp://example.org/basins.zip",
+ ])("rejects %s before any fetch", (url) => {
+ const { error } = validateSourceUrl(url);
+ expect(error.reason).toBe("unsupported_scheme");
+ // The message has to name what is accepted, since the author's next action
+ // is to supply a different URL.
+ expect(error.detail).toMatch(/https?/);
+ });
+
+ it("rejects a path ending in neither .zip nor .shp and names both forms", () => {
+ const { error } = validateSourceUrl(
+ "https://example.org/data/basins.geojson",
+ );
+ expect(error.reason).toBe("unsupported_path");
+ expect(error.detail).toContain(".zip");
+ expect(error.detail).toContain(".shp");
+ });
+
+ it("classifies by the path, not the query string", () => {
+ // A download endpoint whose query says "shp" is still not a .shp path, and a
+ // .zip path with an unrelated query still is an archive.
+ expect(
+ validateSourceUrl("https://example.org/download?format=shp").error,
+ ).toBeTruthy();
+ expect(
+ validateSourceUrl("https://example.org/basins.zip?token=abc").form,
+ ).toBe("archive");
+ });
+
+ it("rejects an empty or non-string url", () => {
+ expect(validateSourceUrl("").error.reason).toBe("empty");
+ expect(validateSourceUrl(undefined).error.reason).toBe("empty");
+ });
+
+ it("rejects a malformed url rather than throwing", () => {
+ expect(validateSourceUrl("https://").error).toBeTruthy();
+ });
+});
+
+describe("deriveSiblingUrls", () => {
+ it("replaces the extension for each component", () => {
+ expect(deriveSiblingUrls("https://example.org/data/basins.shp")).toEqual({
+ shp: "https://example.org/data/basins.shp",
+ dbf: "https://example.org/data/basins.dbf",
+ prj: "https://example.org/data/basins.prj",
+ shx: "https://example.org/data/basins.shx",
+ });
+ });
+
+ it("preserves a query string and fragment untouched", () => {
+ // Presigned links carry a signature computed over the object key, and portal
+ // links carry cache tokens. Replacing the extension across the whole URL
+ // would corrupt both.
+ const derived = deriveSiblingUrls(
+ "https://bucket.s3.amazonaws.com/w/basins.shp?X-Amz-Signature=abc123&X-Amz-Expires=3600#frag",
+ );
+ expect(derived.dbf).toBe(
+ "https://bucket.s3.amazonaws.com/w/basins.dbf?X-Amz-Signature=abc123&X-Amz-Expires=3600#frag",
+ );
+ expect(derived.prj).toContain("?X-Amz-Signature=abc123");
+ expect(derived.prj).toContain("#frag");
+ });
+
+ it("only replaces the final path segment's extension", () => {
+ // A directory named like a component must not be rewritten.
+ const derived = deriveSiblingUrls(
+ "https://example.org/shp.archive/basins.shp",
+ );
+ expect(derived.dbf).toBe("https://example.org/shp.archive/basins.dbf");
+ });
+
+ it("normalizes the derived extension to lower case from an upper-case source", () => {
+ const derived = deriveSiblingUrls("https://example.org/BASINS.SHP");
+ expect(derived.dbf).toBe("https://example.org/BASINS.dbf");
+ });
+
+ it("handles a filename containing dots", () => {
+ const derived = deriveSiblingUrls("https://example.org/wbd.huc8.v2.shp");
+ expect(derived.prj).toBe("https://example.org/wbd.huc8.v2.prj");
+ });
+});
diff --git a/reactapp/__tests__/components/map/shapefile/unzip.test.js b/reactapp/__tests__/components/map/shapefile/unzip.test.js
new file mode 100644
index 00000000..d73cca39
--- /dev/null
+++ b/reactapp/__tests__/components/map/shapefile/unzip.test.js
@@ -0,0 +1,192 @@
+import { zipSync, strToU8, strFromU8 } from "fflate";
+import {
+ unzipShapefileComponents,
+ createByteBudget,
+} from "components/map/shapefile/unzip";
+
+const MB = 1024 * 1024;
+
+function archive(entries) {
+ return zipSync(entries);
+}
+
+const MINIMAL = {
+ "basins.shp": strToU8("SHPBODY"),
+ "basins.dbf": strToU8("DBFBODY"),
+ "basins.prj": strToU8('PROJCS["NAD_1983_Albers"]'),
+ "basins.shx": strToU8("SHXBODY"),
+};
+
+describe("createByteBudget", () => {
+ // The accounting is tested directly because the archives fflate can build
+ // always declare their sizes. A streamed archive that declares none takes the
+ // byte-counting path instead, and this is where that arithmetic lives.
+ it("accepts a total exactly at the ceiling", () => {
+ const budget = createByteBudget(100);
+ expect(budget.add(60)).toBe(true);
+ expect(budget.add(40)).toBe(true);
+ expect(budget.exceeded).toBe(false);
+ expect(budget.observed).toBe(100);
+ });
+
+ it("rejects the byte that crosses the ceiling and stays rejected", () => {
+ const budget = createByteBudget(100);
+ expect(budget.add(60)).toBe(true);
+ expect(budget.add(41)).toBe(false);
+ expect(budget.exceeded).toBe(true);
+ expect(budget.observed).toBe(101);
+ // Once over, it does not recover even if nothing more is added.
+ expect(budget.add(0)).toBe(false);
+ });
+
+ it("rejects a single addition larger than the whole ceiling", () => {
+ const budget = createByteBudget(100);
+ expect(budget.add(1000)).toBe(false);
+ expect(budget.observed).toBe(1000);
+ });
+
+ it("treats an unknown size as zero rather than NaN", () => {
+ const budget = createByteBudget(100);
+ expect(budget.add(undefined)).toBe(true);
+ expect(budget.observed).toBe(0);
+ });
+});
+
+describe("unzipShapefileComponents", () => {
+ it("extracts the four components and decodes nothing", () => {
+ const result = unzipShapefileComponents(archive(MINIMAL), {
+ maxBytes: 10 * MB,
+ });
+ expect(result.error).toBeUndefined();
+ expect(Object.keys(result.components).sort()).toEqual([
+ "dbf",
+ "prj",
+ "shp",
+ "shx",
+ ]);
+ // Buffers come back raw; decoding the .prj is the interpretation step's job.
+ expect(result.components.shp).toBeInstanceOf(Uint8Array);
+ expect(strFromU8(result.components.prj)).toContain("PROJCS");
+ });
+
+ it("decompresses a deflate-compressed archive at all", () => {
+ // Without registering the inflate decoder, fflate's Unzip carries only a
+ // pass-through and every member of a real archive throws on start. This is
+ // the regression guard for that.
+ const compressible = {
+ "basins.shp": new Uint8Array(64 * 1024),
+ "basins.prj": strToU8('PROJCS["x"]'),
+ };
+ const zipped = archive(compressible);
+ expect(zipped.length).toBeLessThan(64 * 1024);
+
+ const result = unzipShapefileComponents(zipped, { maxBytes: 10 * MB });
+ expect(result.error).toBeUndefined();
+ expect(result.components.shp.length).toBe(64 * 1024);
+ });
+
+ it("tolerates components nested in a directory", () => {
+ const result = unzipShapefileComponents(
+ archive({
+ "wbd/basins.shp": strToU8("SHPBODY"),
+ "wbd/basins.prj": strToU8('PROJCS["x"]'),
+ }),
+ { maxBytes: 10 * MB },
+ );
+ expect(result.error).toBeUndefined();
+ expect(result.components.shp).toBeTruthy();
+ });
+
+ it("ignores members that are not shapefile components", () => {
+ const result = unzipShapefileComponents(
+ archive({
+ ...MINIMAL,
+ "readme.txt": strToU8("notes"),
+ "metadata.xml": strToU8(""),
+ }),
+ { maxBytes: 10 * MB },
+ );
+ expect(result.error).toBeUndefined();
+ expect(Object.keys(result.components).sort()).toEqual([
+ "dbf",
+ "prj",
+ "shp",
+ "shx",
+ ]);
+ });
+
+ it("refuses an archive whose declared component size exceeds the ceiling, before expanding it", () => {
+ // 8 MiB of zeros compresses to a few KB, so this is a real bomb ratio: the
+ // ceiling has to bind on the declared expansion, not on the transfer.
+ const zipped = archive({
+ "basins.shp": new Uint8Array(8 * MB),
+ "basins.prj": strToU8('PROJCS["x"]'),
+ });
+ expect(zipped.length).toBeLessThan(64 * 1024);
+
+ const result = unzipShapefileComponents(zipped, { maxBytes: 1 * MB });
+
+ expect(result.components).toBeUndefined();
+ expect(result.error.reason).toBe("too_large");
+ expect(result.error.observed).toBeGreaterThanOrEqual(8 * MB);
+ expect(result.error.permitted).toBe(1 * MB);
+ // The message states both numbers, since the author's next move depends on
+ // how far over the source is.
+ expect(result.error.detail).toMatch(/8|permitted|MB/i);
+ });
+
+ it("refuses on the sum of components, not on any single one", () => {
+ const half = 600 * 1024;
+ const zipped = archive({
+ "basins.shp": new Uint8Array(half),
+ "basins.dbf": new Uint8Array(half),
+ "basins.prj": strToU8('PROJCS["x"]'),
+ });
+ const result = unzipShapefileComponents(zipped, { maxBytes: 1 * MB });
+ expect(result.error.reason).toBe("too_large");
+ });
+
+ it("does not expand a bomb hidden in an irrelevant member", () => {
+ // Members that are not shapefile components are never started, so a bomb
+ // parked in one costs nothing and must not fail the archive either.
+ const zipped = archive({
+ ...MINIMAL,
+ "bomb.bin": new Uint8Array(64 * MB),
+ });
+ const result = unzipShapefileComponents(zipped, { maxBytes: 1 * MB });
+ expect(result.error).toBeUndefined();
+ expect(result.components.shp).toBeTruthy();
+ });
+
+ it("reports an archive carrying more than one .shp rather than choosing", () => {
+ const result = unzipShapefileComponents(
+ archive({
+ "basins.shp": strToU8("A"),
+ "gages.shp": strToU8("B"),
+ "basins.prj": strToU8('PROJCS["x"]'),
+ }),
+ { maxBytes: 10 * MB },
+ );
+ expect(result.components).toBeUndefined();
+ expect(result.error.reason).toBe("ambiguous_archive");
+ expect(result.error.detail).toContain("basins.shp");
+ expect(result.error.detail).toContain("gages.shp");
+ });
+
+ it("reports an archive with no .shp at all", () => {
+ const result = unzipShapefileComponents(
+ archive({ "readme.txt": strToU8("nothing here") }),
+ { maxBytes: 10 * MB },
+ );
+ expect(result.error.reason).toBe("no_shapefile");
+ });
+
+ it("reports a buffer that is not an archive rather than throwing", () => {
+ const result = unzipShapefileComponents(strToU8("404"), {
+ maxBytes: 10 * MB,
+ });
+ expect(result.components).toBeUndefined();
+ expect(result.error.reason).toBe("unreadable_archive");
+ expect(result.error.stage).toBe("parse");
+ });
+});
diff --git a/reactapp/components/map/shapefile/acquire.js b/reactapp/components/map/shapefile/acquire.js
new file mode 100644
index 00000000..5097613b
--- /dev/null
+++ b/reactapp/components/map/shapefile/acquire.js
@@ -0,0 +1,200 @@
+import {
+ validateSourceUrl,
+ deriveSiblingUrls,
+} from "components/map/shapefile/siblings";
+import {
+ unzipShapefileComponents,
+ createByteBudget,
+} from "components/map/shapefile/unzip";
+import {
+ getCachedComponents,
+ setCachedComponents,
+} from "components/map/shapefile/cache";
+
+// The ceiling on how much a shapefile is allowed to expand to. Applied
+// identically here and at view time, because the two paths share this module --
+// a single number is what keeps an author from saving a layer viewers cannot
+// load.
+export const DEFAULT_MAX_BYTES = 25 * 1024 * 1024;
+
+// A browser cannot tell these apart: a cross-origin refusal, an unreachable
+// host, a missing file and an expired signature all surface as the same opaque
+// rejection, with no status and no body. So the message names them together
+// rather than picking one and being wrong.
+const FETCH_STAGE_CAUSES =
+ "The likely causes are missing cross-origin headers on the host, an unreachable host, a URL that no longer exists, or an expired signature on a signed URL.";
+
+function fetchFailure(reason, detail, extra = {}) {
+ return { error: { stage: "fetch", reason, detail, ...extra } };
+}
+
+// A host returning an HTML error page with a success status is common on the
+// portal class this feature targets, and it would otherwise reach the parser as
+// geometry. Only markup is rejected: .prj is legitimately text, and archives are
+// served as everything from application/zip to octet-stream to nothing at all,
+// so allow-listing would break more hosts than it protects.
+function isMarkup(contentType) {
+ if (!contentType) return false;
+ return /^\s*(text\/html|application\/xhtml)/i.test(contentType);
+}
+
+function wasAborted(error, signal) {
+ return signal?.aborted || error?.name === "AbortError";
+}
+
+async function fetchBytes(url, signal) {
+ let response;
+ try {
+ response = await fetch(url, { signal });
+ } catch (error) {
+ if (wasAborted(error, signal)) return { cancelled: true };
+ return fetchFailure(
+ "unreachable",
+ `The shapefile could not be fetched. ${FETCH_STAGE_CAUSES}`,
+ );
+ }
+
+ const contentType = response.headers?.get?.("content-type") ?? "";
+ if (response.ok && isMarkup(contentType)) {
+ return {
+ error: {
+ stage: "parse",
+ reason: "wrong_content_type",
+ detail: `The host returned "${contentType}" rather than shapefile data. A portal error page served with a success status is the usual cause.`,
+ },
+ };
+ }
+
+ if (!response.ok) {
+ return { status: response.status, response };
+ }
+
+ try {
+ // Read the whole body rather than streaming it. Aborting rejects this and
+ // terminates the transfer, which is what cancellation needs, and the
+ // configured test environment exposes no response stream at all -- so a
+ // stream-reader implementation could not be exercised.
+ const buffer = new Uint8Array(await response.arrayBuffer());
+ return { status: response.status, bytes: buffer };
+ } catch (error) {
+ if (wasAborted(error, signal)) return { cancelled: true };
+ return fetchFailure(
+ "unreachable",
+ `The shapefile transfer did not complete. ${FETCH_STAGE_CAUSES}`,
+ );
+ }
+}
+
+async function acquireArchive(url, signal, maxBytes) {
+ const fetched = await fetchBytes(url, signal);
+ if (fetched.cancelled || fetched.error) return fetched;
+ if (fetched.status && fetched.status >= 400) {
+ return fetchFailure(
+ "unreachable",
+ `The shapefile request returned ${fetched.status}. ${FETCH_STAGE_CAUSES}`,
+ { status: fetched.status },
+ );
+ }
+ return unzipShapefileComponents(fetched.bytes, { maxBytes });
+}
+
+async function acquireSiblings(url, signal, maxBytes) {
+ const derived = deriveSiblingUrls(url);
+ const budget = createByteBudget(maxBytes);
+ const components = {};
+
+ // Sequential rather than concurrent: the .shp is required, so there is no
+ // point paying for the other three before knowing it exists, and a shared
+ // budget is simpler to reason about when only one request is in flight.
+ for (const extension of ["shp", "dbf", "prj", "shx"]) {
+ const fetched = await fetchBytes(derived[extension], signal);
+ if (fetched.cancelled) return fetched;
+ if (fetched.error) {
+ // A missing optional component is not an error; a malformed one is.
+ if (fetched.error.stage === "parse" && extension !== "shp") continue;
+ return fetched;
+ }
+
+ if (fetched.status === 404) {
+ // Absence is only meaningful for the optional components. What matters is
+ // that absence and failure stay distinguishable: a transient 403 routed
+ // into the "no projection supplied" fallback would render features at the
+ // wrong location with no error at all.
+ if (extension === "shp") {
+ return fetchFailure(
+ "unreachable",
+ `No shapefile was found at ${derived.shp}. ${FETCH_STAGE_CAUSES}`,
+ { status: 404 },
+ );
+ }
+ continue;
+ }
+
+ if (fetched.status >= 400) {
+ return {
+ error: {
+ stage: "fetch",
+ reason: "component_status",
+ component: extension,
+ status: fetched.status,
+ detail: `The .${extension} component returned ${fetched.status}. It is not being treated as absent, because that would silently change how the layer is drawn.`,
+ },
+ };
+ }
+
+ if (!budget.add(fetched.bytes.length)) {
+ const mb = (bytes) => (bytes / (1024 * 1024)).toFixed(1);
+ return {
+ error: {
+ stage: "fetch",
+ reason: "too_large",
+ observed: budget.observed,
+ permitted: budget.permitted,
+ detail: `The shapefile components total at least ${mb(
+ budget.observed,
+ )} MB, above the ${mb(budget.permitted)} MB permitted.`,
+ },
+ };
+ }
+
+ components[extension] = fetched.bytes;
+ }
+
+ return { components };
+}
+
+/**
+ * Fetch a shapefile's component bytes, from either a zipped archive or an
+ * unzipped set of siblings.
+ *
+ * Knows nothing about what a shapefile means -- it returns raw buffers, and
+ * interpreting them is a separate step. That split is deliberate: the parser
+ * choice carries real risk, and the byte accounting and cancellation contract
+ * here survive a parser swap intact.
+ *
+ * @param {string} rawUrl The author-supplied URL, already interpolated.
+ * @param {{signal?: AbortSignal, maxBytes?: number}} [options]
+ * @returns {Promise<{components: Record, fromCache?: boolean}
+ * |{error: object}|{cancelled: true}>}
+ */
+export async function acquireComponents(
+ rawUrl,
+ { signal, maxBytes = DEFAULT_MAX_BYTES } = {},
+) {
+ const validated = validateSourceUrl(rawUrl);
+ if (validated.error) return validated;
+
+ const cached = getCachedComponents(validated.url);
+ if (cached) return { components: cached, fromCache: true };
+
+ if (signal?.aborted) return { cancelled: true };
+
+ const result =
+ validated.form === "archive"
+ ? await acquireArchive(validated.url, signal, maxBytes)
+ : await acquireSiblings(validated.url, signal, maxBytes);
+
+ // A failed acquisition is never cached, so a retry actually retries.
+ if (result.components) setCachedComponents(validated.url, result.components);
+ return result;
+}
diff --git a/reactapp/components/map/shapefile/cache.js b/reactapp/components/map/shapefile/cache.js
new file mode 100644
index 00000000..7903ba87
--- /dev/null
+++ b/reactapp/components/map/shapefile/cache.js
@@ -0,0 +1,64 @@
+// Cache of decompressed shapefile components, keyed on resolved URL.
+//
+// Layer preservation keeps a layer from refetching when nothing about it
+// changed, but it only helps while the resolved URL stays the same. A variable
+// input driving the URL refetches the whole archive even when toggling back to a
+// value loaded seconds earlier, which is a common interaction on a
+// variable-input dashboard -- and that is what this covers.
+//
+// It caches component buffers rather than parsed features deliberately. Buffers
+// are already under the size ceiling by construction, so a small entry count has
+// an exact memory bound; parsed GeoJSON runs several times the archive size, and
+// any honest byte cap on that would hold about one entry. A hit skips the
+// network hop and the decompression -- the slow, failure-prone part -- and still
+// re-parses, which is fast and deterministic.
+//
+// Scope is the browser session. Entries persist across dashboards visited in one
+// tab, which is correct: the bytes at a URL do not depend on which dashboard
+// asked for them. A host that changes content mid-session serves the cached copy
+// until eviction.
+export const CACHE_MAX_ENTRIES = 3;
+
+// Insertion-ordered, so the first key is the least recently used.
+const entries = new Map();
+
+/**
+ * Look up cached components, marking the entry as most recently used.
+ *
+ * @param {string} key Resolved source URL.
+ * @returns {Record|null}
+ */
+export function getCachedComponents(key) {
+ if (!entries.has(key)) return null;
+ const components = entries.get(key);
+ // Re-insert to move it to the end of the eviction order.
+ entries.delete(key);
+ entries.set(key, components);
+ return components;
+}
+
+/**
+ * Store components against a resolved URL, evicting the least recently used
+ * entry once the cache is full.
+ *
+ * @param {string} key Resolved source URL.
+ * @param {Record} components
+ */
+export function setCachedComponents(key, components) {
+ if (entries.has(key)) entries.delete(key);
+ entries.set(key, components);
+ while (entries.size > CACHE_MAX_ENTRIES) {
+ const oldest = entries.keys().next().value;
+ entries.delete(oldest);
+ }
+}
+
+/** Empty the cache. Exists for tests; nothing in the app needs it. */
+export function clearComponentCache() {
+ entries.clear();
+}
+
+/** Current entry count. Exists for tests. */
+export function cachedComponentCount() {
+ return entries.size;
+}
diff --git a/reactapp/components/map/shapefile/siblings.js b/reactapp/components/map/shapefile/siblings.js
new file mode 100644
index 00000000..07d2653c
--- /dev/null
+++ b/reactapp/components/map/shapefile/siblings.js
@@ -0,0 +1,99 @@
+// The components of an unzipped shapefile. `shp` carries geometry, `dbf`
+// attributes, `prj` the coordinate reference system as WKT, and `shx` the record
+// index.
+export const COMPONENT_EXTENSIONS = ["shp", "dbf", "prj", "shx"];
+
+const ALLOWED_PROTOCOLS = ["http:", "https:"];
+
+function failure(reason, detail) {
+ return { error: { stage: "fetch", reason, detail } };
+}
+
+// The extension of the final path segment, lower-cased, or "" when there is
+// none. Read from the path alone: a download endpoint whose query string says
+// `format=shp` is not a .shp path, and a .zip path carrying a cache token still
+// is an archive.
+function pathExtension(url) {
+ const segments = url.pathname.split("/");
+ const last = segments[segments.length - 1];
+ const dot = last.lastIndexOf(".");
+ return dot === -1 ? "" : last.slice(dot + 1).toLowerCase();
+}
+
+/**
+ * Decide whether a source URL is usable, and which of the two accepted forms it
+ * is.
+ *
+ * Runs before any fetch. The scheme restriction is the point: an author-supplied
+ * `data:` URI would carry an entire base64 archive into the saved layer
+ * configuration, which is exactly the storage accumulation that referencing a
+ * remote URL exists to avoid.
+ *
+ * @param {string} rawUrl The author-supplied URL.
+ * @returns {{form: "archive"|"components", url: string}|{error: object}}
+ */
+export function validateSourceUrl(rawUrl) {
+ if (typeof rawUrl !== "string" || rawUrl.trim() === "") {
+ return failure("empty", "No shapefile URL was supplied.");
+ }
+
+ const trimmed = rawUrl.trim();
+
+ // Checked before parsing, because a protocol-relative URL has no protocol to
+ // report and would otherwise surface as an unhelpful malformed-URL error.
+ if (trimmed.startsWith("//")) {
+ return failure(
+ "unsupported_scheme",
+ "A protocol-relative URL is not accepted. Use an http:// or https:// URL.",
+ );
+ }
+
+ let url;
+ try {
+ url = new URL(trimmed);
+ } catch {
+ return failure("malformed_url", `"${trimmed}" is not a valid URL.`);
+ }
+
+ if (!ALLOWED_PROTOCOLS.includes(url.protocol)) {
+ return failure(
+ "unsupported_scheme",
+ `The scheme "${url.protocol}" is not accepted. Use an http:// or https:// URL.`,
+ );
+ }
+
+ const extension = pathExtension(url);
+ if (extension === "zip") return { form: "archive", url: trimmed };
+ if (extension === "shp") return { form: "components", url: trimmed };
+
+ return failure(
+ "unsupported_path",
+ "The URL path must end in .zip for a zipped shapefile, or .shp for an unzipped one.",
+ );
+}
+
+/**
+ * Derive the sibling component URLs from a `.shp` URL.
+ *
+ * Only the final path segment's extension is replaced; the query string and
+ * fragment are carried through untouched. Presigned links compute their
+ * signature over the object key and portal links carry cache tokens, so
+ * rewriting anything outside the path corrupts the request.
+ *
+ * @param {string} shpUrl A validated `.shp` URL.
+ * @returns {Record} One URL per component extension.
+ */
+export function deriveSiblingUrls(shpUrl) {
+ const url = new URL(shpUrl);
+ const segments = url.pathname.split("/");
+ const last = segments[segments.length - 1];
+ const stem = last.slice(0, last.lastIndexOf("."));
+
+ return COMPONENT_EXTENSIONS.reduce((derived, extension) => {
+ const rebuilt = new URL(url.toString());
+ rebuilt.pathname = [...segments.slice(0, -1), `${stem}.${extension}`].join(
+ "/",
+ );
+ return { ...derived, [extension]: rebuilt.toString() };
+ }, {});
+}
diff --git a/reactapp/components/map/shapefile/unzip.js b/reactapp/components/map/shapefile/unzip.js
new file mode 100644
index 00000000..a12a2428
--- /dev/null
+++ b/reactapp/components/map/shapefile/unzip.js
@@ -0,0 +1,194 @@
+import { Unzip, UnzipInflate } from "fflate";
+import { COMPONENT_EXTENSIONS } from "components/map/shapefile/siblings";
+
+/**
+ * Running total against a ceiling, for bounding how much a source is allowed to
+ * expand to.
+ *
+ * Separate from the unzip loop because the archives available to a test always
+ * declare their member sizes, while a streamed archive declares none and takes
+ * the byte-counting path instead. Keeping the arithmetic here makes both
+ * testable.
+ *
+ * @param {number} maxBytes The ceiling, in bytes.
+ */
+export function createByteBudget(maxBytes) {
+ return {
+ observed: 0,
+ exceeded: false,
+ permitted: maxBytes,
+ add(bytes) {
+ this.observed += Number.isFinite(bytes) ? bytes : 0;
+ if (this.observed > maxBytes) this.exceeded = true;
+ return !this.exceeded;
+ },
+ };
+}
+
+function componentExtension(name) {
+ const base = name.split("/").pop() ?? "";
+ const dot = base.lastIndexOf(".");
+ if (dot === -1) return null;
+ const extension = base.slice(dot + 1).toLowerCase();
+ return COMPONENT_EXTENSIONS.includes(extension) ? extension : null;
+}
+
+function tooLarge(budget) {
+ const mb = (bytes) => (bytes / (1024 * 1024)).toFixed(1);
+ return {
+ error: {
+ stage: "fetch",
+ reason: "too_large",
+ observed: budget.observed,
+ permitted: budget.permitted,
+ detail: `The shapefile expands to at least ${mb(
+ budget.observed,
+ )} MB, above the ${mb(budget.permitted)} MB permitted.`,
+ },
+ };
+}
+
+/**
+ * Extract the shapefile components from a zipped archive, bounded by how much
+ * they are allowed to expand to.
+ *
+ * The ceiling is applied to each member's *declared* size, read from the local
+ * header before any data flows, and refused by simply never starting that
+ * member. Summing bytes as they arrive does not work: a 200 MB expansion can
+ * arrive in a single callback, so a running total notices it only once the whole
+ * payload is already allocated and inflated -- which is the cost the ceiling
+ * exists to prevent. Members with no declared size fall back to counting.
+ *
+ * Only shapefile components are ever started, so a bomb parked in an unrelated
+ * member costs nothing.
+ *
+ * @param {Uint8Array} buffer The archive bytes.
+ * @param {{maxBytes: number}} options
+ * @returns {{components: Record}|{error: object}}
+ */
+export function unzipShapefileComponents(buffer, { maxBytes }) {
+ // Every zip starts "PK" -- 0x03 0x04 for a normal entry, 0x05 0x06 for an
+ // empty archive, 0x07 0x08 for a spanned one. Checked up front because pushing
+ // something else into Unzip does not fail: it simply finds no entries, and the
+ // author would be told the archive has no .shp entry when the real answer is
+ // that a portal returned an HTML error page with a 200.
+ if (!(buffer?.length >= 4 && buffer[0] === 0x50 && buffer[1] === 0x4b)) {
+ return {
+ error: {
+ stage: "parse",
+ reason: "unreadable_archive",
+ detail:
+ "The source is not a zip archive. A portal returning an error page with a success status is the usual cause.",
+ },
+ };
+ }
+
+ const components = {};
+ const shpMembers = [];
+ const budget = createByteBudget(maxBytes);
+ let failure = null;
+
+ const unzip = new Unzip();
+ // Without this, Unzip carries only a pass-through decoder and every member of
+ // a deflate-compressed archive -- which is to say every real archive -- throws
+ // on start.
+ unzip.register(UnzipInflate);
+
+ unzip.onfile = (file) => {
+ const extension = componentExtension(file.name);
+ if (!extension || failure) return;
+ if (extension === "shp") shpMembers.push(file.name);
+
+ const declared = file.originalSize;
+ if (Number.isFinite(declared) && declared > 0) {
+ if (!budget.add(declared)) {
+ failure = tooLarge(budget);
+ return;
+ }
+ }
+
+ const chunks = [];
+ const declaredKnown = Number.isFinite(declared) && declared > 0;
+ file.ondata = (error, chunk, final) => {
+ if (error) {
+ failure = failure ?? {
+ error: {
+ stage: "parse",
+ reason: "unreadable_component",
+ detail: `The "${file.name}" entry could not be read: ${error.message}`,
+ },
+ };
+ return;
+ }
+ // Counted only when the header declared nothing, so a declared member is
+ // not charged twice.
+ if (!declaredKnown) {
+ if (!budget.add(chunk.length)) {
+ failure = tooLarge(budget);
+ file.terminate?.();
+ return;
+ }
+ }
+ chunks.push(chunk);
+ if (final) {
+ const total = chunks.reduce((sum, part) => sum + part.length, 0);
+ const merged = new Uint8Array(total);
+ chunks.reduce((offset, part) => {
+ merged.set(part, offset);
+ return offset + part.length;
+ }, 0);
+ components[extension] = merged;
+ }
+ };
+
+ try {
+ file.start();
+ } catch (error) {
+ failure = failure ?? {
+ error: {
+ stage: "parse",
+ reason: "unreadable_component",
+ detail: `The "${file.name}" entry could not be decompressed: ${error.message}`,
+ },
+ };
+ }
+ };
+
+ try {
+ unzip.push(buffer, true);
+ } catch (error) {
+ return {
+ error: {
+ stage: "parse",
+ reason: "unreadable_archive",
+ detail: `The source could not be read as a zip archive: ${error.message}`,
+ },
+ };
+ }
+
+ if (failure) return failure;
+
+ if (shpMembers.length > 1) {
+ return {
+ error: {
+ stage: "parse",
+ reason: "ambiguous_archive",
+ detail: `The archive contains more than one shapefile (${shpMembers.join(
+ ", ",
+ )}). Point the URL at a single shapefile instead.`,
+ },
+ };
+ }
+
+ if (!components.shp) {
+ return {
+ error: {
+ stage: "parse",
+ reason: "no_shapefile",
+ detail: "The archive contains no .shp entry.",
+ },
+ };
+ }
+
+ return { components };
+}
From 9a81177ce596e27bd186b61335fbc24d9b4db3e4 Mon Sep 17 00:00:00 2001
From: Corey Krewson
Date: Tue, 25 Aug 2026 10:16:28 -0700
Subject: [PATCH 03/18] feat(map): interpret shapefile components into a
georeferenced collection
Parses the geometry, resolves the coordinate reference, normalizes ring
winding, and returns a plain GeoJSON feature collection whose `crs` names its
projection -- the same payload shape the existing vector-swap path already
consumes, so the two vector paths stay interchangeable and no OpenLayers
object is built here.
The parser choice is now settled rather than assumed. Fixtures are real
shapefile bytes generated by pyshp, and the expectations come from pyshp
reading them back through its own independent ring-nesting implementation --
so the fidelity test is a cross-implementation check. It passes: a polygon
with an interior ring reads as one polygon with a hole, and a multi-part
record as one MultiPolygon, both matching pyshp coordinate for coordinate.
That was the largest risk in the plan.
The browser build is imported by name. The package resolves to a Node build
under the test runner and a browser build under the bundler, and a fidelity
guarantee measured against an artifact that never ships is worth nothing.
That needs TextDecoder, which jsdom lacks, so setupTests now supplies it --
which also moves tests closer to a real browser than the Node build's
bundled decoder.
Absence and failure stay distinct. A genuinely missing .prj falls back to the
author-supplied projection; a .prj that is present but unresolvable is
reported, never quietly replaced by the fallback, because the file said what
it was and drawing it with a guessed projection would put the features
somewhere else with no error.
One test-harness trap worth recording: with a native TextEncoder available,
fflate's strToU8 returns a Uint8Array from the Node realm, so fflate's own
instanceof check fails and zipSync recurses into the byte indices, producing
archive members named "basins.shp/0/" instead of a file. Fixtures now build
their bytes in the test realm. Only fixture construction was affected --
nothing in the application builds archives.
Co-Authored-By: Claude Opus 5 (1M context)
---
.../components/map/shapefile/acquire.test.js | 17 +-
.../components/map/shapefile/index.test.js | 209 ++++++++++++++++++
.../components/map/shapefile/unzip.test.js | 37 ++--
reactapp/__tests__/setupTests.js | 10 +
reactapp/__tests__/utilities/bytes.js | 19 ++
.../utilities/fixtures/shapefile/generate.py | 80 +++++++
.../utilities/fixtures/shapefile/holes.dbf | Bin 0 -> 150 bytes
.../utilities/fixtures/shapefile/holes.prj | 1 +
.../utilities/fixtures/shapefile/holes.shp | Bin 0 -> 320 bytes
.../utilities/fixtures/shapefile/holes.shx | Bin 0 -> 108 bytes
.../fixtures/shapefile/multipart.dbf | Bin 0 -> 106 bytes
.../fixtures/shapefile/multipart.prj | 1 +
.../fixtures/shapefile/multipart.shp | Bin 0 -> 320 bytes
.../fixtures/shapefile/multipart.shx | Bin 0 -> 108 bytes
.../utilities/fixtures/shapefile/points.dbf | Bin 0 -> 139 bytes
.../utilities/fixtures/shapefile/points.prj | 1 +
.../utilities/fixtures/shapefile/points.shp | Bin 0 -> 156 bytes
.../utilities/fixtures/shapefile/points.shx | Bin 0 -> 116 bytes
reactapp/components/map/shapefile/index.js | 134 +++++++++++
19 files changed, 483 insertions(+), 26 deletions(-)
create mode 100644 reactapp/__tests__/components/map/shapefile/index.test.js
create mode 100644 reactapp/__tests__/utilities/bytes.js
create mode 100644 reactapp/__tests__/utilities/fixtures/shapefile/generate.py
create mode 100644 reactapp/__tests__/utilities/fixtures/shapefile/holes.dbf
create mode 100644 reactapp/__tests__/utilities/fixtures/shapefile/holes.prj
create mode 100644 reactapp/__tests__/utilities/fixtures/shapefile/holes.shp
create mode 100644 reactapp/__tests__/utilities/fixtures/shapefile/holes.shx
create mode 100644 reactapp/__tests__/utilities/fixtures/shapefile/multipart.dbf
create mode 100644 reactapp/__tests__/utilities/fixtures/shapefile/multipart.prj
create mode 100644 reactapp/__tests__/utilities/fixtures/shapefile/multipart.shp
create mode 100644 reactapp/__tests__/utilities/fixtures/shapefile/multipart.shx
create mode 100644 reactapp/__tests__/utilities/fixtures/shapefile/points.dbf
create mode 100644 reactapp/__tests__/utilities/fixtures/shapefile/points.prj
create mode 100644 reactapp/__tests__/utilities/fixtures/shapefile/points.shp
create mode 100644 reactapp/__tests__/utilities/fixtures/shapefile/points.shx
create mode 100644 reactapp/components/map/shapefile/index.js
diff --git a/reactapp/__tests__/components/map/shapefile/acquire.test.js b/reactapp/__tests__/components/map/shapefile/acquire.test.js
index da580371..f165f7f2 100644
--- a/reactapp/__tests__/components/map/shapefile/acquire.test.js
+++ b/reactapp/__tests__/components/map/shapefile/acquire.test.js
@@ -1,4 +1,5 @@
-import { zipSync, strToU8 } from "fflate";
+import { zipSync } from "fflate";
+import { bytes } from "../../../utilities/bytes";
import {
acquireComponents,
DEFAULT_MAX_BYTES,
@@ -11,10 +12,10 @@ import {
const MB = 1024 * 1024;
const ARCHIVE = zipSync({
- "basins.shp": strToU8("SHPBODY"),
- "basins.dbf": strToU8("DBFBODY"),
- "basins.prj": strToU8('PROJCS["NAD_1983_Albers"]'),
- "basins.shx": strToU8("SHXBODY"),
+ "basins.shp": bytes("SHPBODY"),
+ "basins.dbf": bytes("DBFBODY"),
+ "basins.prj": bytes('PROJCS["NAD_1983_Albers"]'),
+ "basins.shx": bytes("SHXBODY"),
});
// Minimal Response stand-in. The configured environment exposes no response
@@ -77,7 +78,7 @@ describe("acquireComponents — archive form", () => {
fetchMock.mockResolvedValue(
respond({
contentType: "text/html; charset=utf-8",
- body: strToU8(""),
+ body: bytes(""),
}),
);
@@ -112,7 +113,7 @@ describe("acquireComponents — archive form", () => {
it("refuses an archive that expands past the ceiling", async () => {
const bomb = zipSync({
"basins.shp": new Uint8Array(8 * MB),
- "basins.prj": strToU8('PROJCS["x"]'),
+ "basins.prj": bytes('PROJCS["x"]'),
});
fetchMock.mockResolvedValue(respond({ body: bomb }));
@@ -133,7 +134,7 @@ describe("acquireComponents — sibling form", () => {
return Promise.resolve(
respond({
contentType: "application/octet-stream",
- body: strToU8(`${extension.toUpperCase()}BODY`),
+ body: bytes(`${extension.toUpperCase()}BODY`),
}),
);
};
diff --git a/reactapp/__tests__/components/map/shapefile/index.test.js b/reactapp/__tests__/components/map/shapefile/index.test.js
new file mode 100644
index 00000000..82d85fa6
--- /dev/null
+++ b/reactapp/__tests__/components/map/shapefile/index.test.js
@@ -0,0 +1,209 @@
+import fs from "fs";
+import path from "path";
+import { bytes } from "../../../utilities/bytes";
+import { interpretShapefile } from "components/map/shapefile/index";
+
+// Read from disk rather than imported: the asset transform turns a non-JS import
+// into its filename string, so a binary fixture cannot be `import`ed.
+const FIXTURES = path.join(__dirname, "../../../utilities/fixtures/shapefile");
+
+function load(name) {
+ const components = {};
+ ["shp", "dbf", "prj", "shx"].forEach((extension) => {
+ const file = path.join(FIXTURES, `${name}.${extension}`);
+ if (fs.existsSync(file)) {
+ components[extension] = new Uint8Array(fs.readFileSync(file));
+ }
+ });
+ return components;
+}
+
+describe("interpretShapefile — geometry fidelity", () => {
+ // These expectations come from pyshp reading the same files back through its
+ // own ring-nesting implementation, which makes this a cross-implementation
+ // check. Shapefile encodes interior rings by winding direction with no parent
+ // pointer, so a parser that re-derives containment wrongly draws a basin's
+ // holes as filled polygons on top of it -- and the better-known JS parser has
+ // a filed bug for exactly that. This is the gate on the parser choice.
+ it("reads a polygon with an interior ring as one polygon with a hole", async () => {
+ const result = await interpretShapefile(load("holes"));
+
+ expect(result.error).toBeUndefined();
+ const [feature] = result.featureCollection.features;
+ expect(feature.geometry.type).toBe("Polygon");
+ // Two rings on one polygon -- not two separate polygons, and not a
+ // MultiPolygon.
+ expect(feature.geometry.coordinates).toHaveLength(2);
+ const [exterior, interior] = feature.geometry.coordinates;
+ expect(exterior).toHaveLength(5);
+ expect(interior).toHaveLength(5);
+ // The interior ring is the inner square, whatever winding it ended up with.
+ const interiorXs = interior.map(([x]) => x).sort((a, b) => a - b);
+ expect(interiorXs[0]).toBe(3);
+ expect(interiorXs[interiorXs.length - 1]).toBe(7);
+ });
+
+ it("reads a multi-part record as one multi-geometry feature", async () => {
+ const result = await interpretShapefile(load("multipart"));
+
+ expect(result.error).toBeUndefined();
+ // One record in, one feature out -- not two features.
+ expect(result.featureCollection.features).toHaveLength(1);
+ const [feature] = result.featureCollection.features;
+ expect(feature.geometry.type).toBe("MultiPolygon");
+ expect(feature.geometry.coordinates).toHaveLength(2);
+ expect(feature.properties.NAME).toBe("Two islands, one record");
+ });
+
+ it("normalizes ring winding to the GeoJSON spec", async () => {
+ // Shapefile writes exterior rings clockwise; the GeoJSON spec wants them
+ // counter-clockwise. Signed area is positive for a counter-clockwise ring.
+ const result = await interpretShapefile(load("holes"));
+ const [exterior] =
+ result.featureCollection.features[0].geometry.coordinates;
+ const area = exterior.reduce((sum, [x1, y1], index) => {
+ const [x2, y2] = exterior[(index + 1) % exterior.length];
+ return sum + (x1 * y2 - x2 * y1);
+ }, 0);
+ expect(area).toBeGreaterThan(0);
+ });
+
+ it("reads points with their attributes", async () => {
+ const result = await interpretShapefile(load("points"));
+
+ expect(result.featureCollection.features).toHaveLength(2);
+ expect(result.featureCollection.features[0].geometry.type).toBe("Point");
+ expect(result.featureCollection.features[0].properties).toEqual({
+ GAGE_ID: "06730200",
+ STAGE_FT: 4.25,
+ });
+ });
+});
+
+describe("interpretShapefile — payload contract", () => {
+ it("names the resolved projection on the collection's crs", async () => {
+ const result = await interpretShapefile(load("holes"));
+ // The existing vector-swap path reads dataProjection from exactly here, so
+ // both vector paths produce interchangeable payloads.
+ expect(result.featureCollection.crs.properties.name).toBe(
+ result.projectionCode,
+ );
+ expect(result.projectionCode).toMatch(/^WKT:/);
+ });
+
+ it("returns plain GeoJSON with no OpenLayers objects", async () => {
+ const result = await interpretShapefile(load("holes"));
+ expect(result.featureCollection.type).toBe("FeatureCollection");
+ // Round-trips through JSON, which an OpenLayers feature would not.
+ expect(() => JSON.stringify(result.featureCollection)).not.toThrow();
+ });
+});
+
+describe("interpretShapefile — attributes", () => {
+ it("reads geometry with no attributes when the .dbf is absent", async () => {
+ const components = load("holes");
+ delete components.dbf;
+
+ const result = await interpretShapefile(components);
+
+ expect(result.error).toBeUndefined();
+ expect(result.featureCollection.features[0].geometry.type).toBe("Polygon");
+ expect(result.featureCollection.features[0].properties).toEqual({});
+ });
+});
+
+describe("interpretShapefile — projection resolution", () => {
+ it("registers the .prj and uses it", async () => {
+ const result = await interpretShapefile(load("holes"));
+ expect(result.error).toBeUndefined();
+ expect(result.projectionCode).toBeTruthy();
+ });
+
+ it("falls back to the supplied projection when there is no .prj", async () => {
+ const components = load("holes");
+ delete components.prj;
+
+ const result = await interpretShapefile(components, {
+ fallbackProjection: "EPSG:5070",
+ });
+
+ expect(result.error).toBeUndefined();
+ expect(result.projectionCode).toBe("EPSG:5070");
+ expect(result.featureCollection.crs.properties.name).toBe("EPSG:5070");
+ });
+
+ it("reports a missing projection with no fallback rather than guessing", async () => {
+ const components = load("holes");
+ delete components.prj;
+
+ const result = await interpretShapefile(components);
+
+ expect(result.featureCollection).toBeUndefined();
+ expect(result.error.reason).toBe("missing_projection");
+ expect(result.error.stage).toBe("parse");
+ });
+
+ it("reports an unresolvable fallback projection by name", async () => {
+ const components = load("holes");
+ delete components.prj;
+
+ const result = await interpretShapefile(components, {
+ fallbackProjection: "EPSG:99999",
+ });
+
+ expect(result.error.reason).toBe("unresolvable_projection");
+ expect(result.error.detail).toContain("EPSG:99999");
+ });
+
+ it("reports an unresolvable .prj naming the projection method", async () => {
+ const components = load("holes");
+ components.prj = bytes(
+ 'PROJCS["x",GEOGCS["g",DATUM["d",SPHEROID["s",6378137,298.257222101]],PRIMEM["Greenwich",0],UNIT["Degree",0.0174532925199433]],PROJECTION["Totally_Not_A_Real_Projection"],UNIT["Meter",1.0]]',
+ );
+
+ const result = await interpretShapefile(components);
+
+ expect(result.error.reason).toBe("unresolvable_projection");
+ expect(result.error.detail).toContain("Totally_Not_A_Real_Projection");
+ });
+
+ it("does not fall back when a .prj is present but unresolvable", async () => {
+ // A present-but-broken .prj must not quietly become the fallback's problem:
+ // the file said what it was and the answer is to report it, not to draw the
+ // features using a projection the author guessed.
+ const components = load("holes");
+ components.prj = bytes("not wkt at all");
+
+ const result = await interpretShapefile(components, {
+ fallbackProjection: "EPSG:5070",
+ });
+
+ expect(result.featureCollection).toBeUndefined();
+ expect(result.error.reason).toBe("unresolvable_projection");
+ });
+});
+
+describe("interpretShapefile — failure paths", () => {
+ it("reports missing geometry", async () => {
+ const result = await interpretShapefile({ prj: bytes('PROJCS["x"]') });
+ expect(result.error.reason).toBe("no_geometry");
+ });
+
+ it("reports unreadable geometry rather than throwing", async () => {
+ const components = load("holes");
+ components.shp = new Uint8Array([1, 2, 3, 4, 5, 6, 7, 8]);
+
+ const result = await interpretShapefile(components);
+
+ expect(result.featureCollection).toBeUndefined();
+ expect(result.error.reason).toBe("unreadable_geometry");
+ expect(result.error.stage).toBe("parse");
+ });
+
+ it("reports an empty components object", async () => {
+ expect((await interpretShapefile({})).error.reason).toBe("no_geometry");
+ expect((await interpretShapefile(undefined)).error.reason).toBe(
+ "no_geometry",
+ );
+ });
+});
diff --git a/reactapp/__tests__/components/map/shapefile/unzip.test.js b/reactapp/__tests__/components/map/shapefile/unzip.test.js
index d73cca39..5ba977aa 100644
--- a/reactapp/__tests__/components/map/shapefile/unzip.test.js
+++ b/reactapp/__tests__/components/map/shapefile/unzip.test.js
@@ -1,4 +1,5 @@
-import { zipSync, strToU8, strFromU8 } from "fflate";
+import { zipSync } from "fflate";
+import { bytes, text } from "../../../utilities/bytes";
import {
unzipShapefileComponents,
createByteBudget,
@@ -11,10 +12,10 @@ function archive(entries) {
}
const MINIMAL = {
- "basins.shp": strToU8("SHPBODY"),
- "basins.dbf": strToU8("DBFBODY"),
- "basins.prj": strToU8('PROJCS["NAD_1983_Albers"]'),
- "basins.shx": strToU8("SHXBODY"),
+ "basins.shp": bytes("SHPBODY"),
+ "basins.dbf": bytes("DBFBODY"),
+ "basins.prj": bytes('PROJCS["NAD_1983_Albers"]'),
+ "basins.shx": bytes("SHXBODY"),
};
describe("createByteBudget", () => {
@@ -66,7 +67,7 @@ describe("unzipShapefileComponents", () => {
]);
// Buffers come back raw; decoding the .prj is the interpretation step's job.
expect(result.components.shp).toBeInstanceOf(Uint8Array);
- expect(strFromU8(result.components.prj)).toContain("PROJCS");
+ expect(text(result.components.prj)).toContain("PROJCS");
});
it("decompresses a deflate-compressed archive at all", () => {
@@ -75,7 +76,7 @@ describe("unzipShapefileComponents", () => {
// the regression guard for that.
const compressible = {
"basins.shp": new Uint8Array(64 * 1024),
- "basins.prj": strToU8('PROJCS["x"]'),
+ "basins.prj": bytes('PROJCS["x"]'),
};
const zipped = archive(compressible);
expect(zipped.length).toBeLessThan(64 * 1024);
@@ -88,8 +89,8 @@ describe("unzipShapefileComponents", () => {
it("tolerates components nested in a directory", () => {
const result = unzipShapefileComponents(
archive({
- "wbd/basins.shp": strToU8("SHPBODY"),
- "wbd/basins.prj": strToU8('PROJCS["x"]'),
+ "wbd/basins.shp": bytes("SHPBODY"),
+ "wbd/basins.prj": bytes('PROJCS["x"]'),
}),
{ maxBytes: 10 * MB },
);
@@ -101,8 +102,8 @@ describe("unzipShapefileComponents", () => {
const result = unzipShapefileComponents(
archive({
...MINIMAL,
- "readme.txt": strToU8("notes"),
- "metadata.xml": strToU8(""),
+ "readme.txt": bytes("notes"),
+ "metadata.xml": bytes(""),
}),
{ maxBytes: 10 * MB },
);
@@ -120,7 +121,7 @@ describe("unzipShapefileComponents", () => {
// ceiling has to bind on the declared expansion, not on the transfer.
const zipped = archive({
"basins.shp": new Uint8Array(8 * MB),
- "basins.prj": strToU8('PROJCS["x"]'),
+ "basins.prj": bytes('PROJCS["x"]'),
});
expect(zipped.length).toBeLessThan(64 * 1024);
@@ -140,7 +141,7 @@ describe("unzipShapefileComponents", () => {
const zipped = archive({
"basins.shp": new Uint8Array(half),
"basins.dbf": new Uint8Array(half),
- "basins.prj": strToU8('PROJCS["x"]'),
+ "basins.prj": bytes('PROJCS["x"]'),
});
const result = unzipShapefileComponents(zipped, { maxBytes: 1 * MB });
expect(result.error.reason).toBe("too_large");
@@ -161,9 +162,9 @@ describe("unzipShapefileComponents", () => {
it("reports an archive carrying more than one .shp rather than choosing", () => {
const result = unzipShapefileComponents(
archive({
- "basins.shp": strToU8("A"),
- "gages.shp": strToU8("B"),
- "basins.prj": strToU8('PROJCS["x"]'),
+ "basins.shp": bytes("A"),
+ "gages.shp": bytes("B"),
+ "basins.prj": bytes('PROJCS["x"]'),
}),
{ maxBytes: 10 * MB },
);
@@ -175,14 +176,14 @@ describe("unzipShapefileComponents", () => {
it("reports an archive with no .shp at all", () => {
const result = unzipShapefileComponents(
- archive({ "readme.txt": strToU8("nothing here") }),
+ archive({ "readme.txt": bytes("nothing here") }),
{ maxBytes: 10 * MB },
);
expect(result.error.reason).toBe("no_shapefile");
});
it("reports a buffer that is not an archive rather than throwing", () => {
- const result = unzipShapefileComponents(strToU8("404"), {
+ const result = unzipShapefileComponents(bytes("404"), {
maxBytes: 10 * MB,
});
expect(result.components).toBeUndefined();
diff --git a/reactapp/__tests__/setupTests.js b/reactapp/__tests__/setupTests.js
index 65d03009..de0beeac 100644
--- a/reactapp/__tests__/setupTests.js
+++ b/reactapp/__tests__/setupTests.js
@@ -95,3 +95,13 @@ HTMLCanvasElement.prototype.getContext = function () {
jest.mock("uuid", () => ({
v4: () => 12345678,
}));
+
+// jsdom ships no TextDecoder/TextEncoder, but every browser does. Without these,
+// any dependency's browser build that decodes text -- the shapefile parser
+// reading a .dbf, for one -- fails only under test. Supplying them lets tests
+// exercise the same build the bundle ships rather than a Node-only fallback.
+if (typeof global.TextDecoder === "undefined") {
+ const { TextDecoder, TextEncoder } = require("util");
+ global.TextDecoder = TextDecoder;
+ global.TextEncoder = TextEncoder;
+}
diff --git a/reactapp/__tests__/utilities/bytes.js b/reactapp/__tests__/utilities/bytes.js
new file mode 100644
index 00000000..d1065d1f
--- /dev/null
+++ b/reactapp/__tests__/utilities/bytes.js
@@ -0,0 +1,19 @@
+// Build a Uint8Array in the test realm.
+//
+// fflate's `strToU8` uses the native TextEncoder when one is available, and
+// under jsdom that returns a Uint8Array belonging to the Node realm. fflate's
+// own `instanceof Uint8Array` check then fails, so `zipSync` treats the value as
+// a plain object and recurses into its byte indices -- producing archive members
+// named "basins.shp/0/" instead of a file. Constructing here keeps the array in
+// the realm the code under test compares against.
+//
+// Only fixture construction is affected; reading bytes back with `strFromU8`
+// works across realms, and nothing in the application builds archives.
+export function bytes(text) {
+ return Uint8Array.from(text, (character) => character.charCodeAt(0));
+}
+
+/** Decode ASCII bytes back to a string, realm-independently. */
+export function text(byteArray) {
+ return Array.from(byteArray, (byte) => String.fromCharCode(byte)).join("");
+}
diff --git a/reactapp/__tests__/utilities/fixtures/shapefile/generate.py b/reactapp/__tests__/utilities/fixtures/shapefile/generate.py
new file mode 100644
index 00000000..f266af23
--- /dev/null
+++ b/reactapp/__tests__/utilities/fixtures/shapefile/generate.py
@@ -0,0 +1,80 @@
+"""Regenerate the shapefile test fixtures.
+
+Run from the repo root: python3 reactapp/__tests__/utilities/fixtures/shapefile/generate.py
+
+These are real shapefile bytes rather than hand-crafted ones, because the
+behavior under test is how a parser interprets the format's own conventions --
+notably that polygon interior rings are encoded by winding direction with no
+parent pointer. pyshp writes the files and also reads them back through its own
+independent ring-nesting implementation, which is what makes the expected
+GeoJSON in index.test.js a cross-implementation oracle rather than a
+self-consistency check.
+"""
+
+import json
+import os
+
+import shapefile
+
+HERE = os.path.dirname(os.path.abspath(__file__))
+
+ESRI_ALBERS_PRJ = (
+ 'PROJCS["NAD_1983_Albers",GEOGCS["GCS_North_American_1983",'
+ 'DATUM["D_North_American_1983",SPHEROID["GRS_1980",6378137.0,298.257222101]],'
+ 'PRIMEM["Greenwich",0.0],UNIT["Degree",0.0174532925199433]],'
+ 'PROJECTION["Albers"],PARAMETER["False_Easting",0.0],'
+ 'PARAMETER["False_Northing",0.0],PARAMETER["Central_Meridian",-96.0],'
+ 'PARAMETER["Standard_Parallel_1",29.5],PARAMETER["Standard_Parallel_2",45.5],'
+ 'PARAMETER["Latitude_Of_Origin",23.0],UNIT["Meter",1.0]]'
+)
+
+# Shapefile convention: exterior rings clockwise, interior rings counter-clockwise.
+OUTER_CW = [(0, 0), (0, 10), (10, 10), (10, 0), (0, 0)]
+HOLE_CCW = [(3, 3), (7, 3), (7, 7), (3, 7), (3, 3)]
+ISLAND_A_CW = [(0, 0), (0, 4), (4, 4), (4, 0), (0, 0)]
+ISLAND_B_CW = [(20, 20), (20, 24), (24, 24), (24, 20), (20, 20)]
+
+
+def write(name, build):
+ path = os.path.join(HERE, name)
+ writer = shapefile.Writer(path)
+ build(writer)
+ writer.close()
+ with open(path + ".prj", "w") as handle:
+ handle.write(ESRI_ALBERS_PRJ)
+ with shapefile.Reader(path) as reader:
+ return {
+ "shapeType": reader.shapeTypeName,
+ "fields": [f[0] for f in reader.fields if f[0] != "DeletionFlag"],
+ "features": [s.__geo_interface__ for s in reader.shapeRecords()],
+ }
+
+
+def holes(writer):
+ writer.field("NAME", "C", 40)
+ writer.field("AREASQKM", "N", 12, 3)
+ writer.poly([OUTER_CW, HOLE_CCW])
+ writer.record("Basin with hole", 91.0)
+
+
+def multipart(writer):
+ writer.field("NAME", "C", 40)
+ writer.poly([ISLAND_A_CW, ISLAND_B_CW])
+ writer.record("Two islands, one record")
+
+
+def points(writer):
+ writer.field("GAGE_ID", "C", 12)
+ writer.field("STAGE_FT", "N", 8, 2)
+ writer.point(-105.0, 40.0)
+ writer.record("06730200", 4.25)
+ writer.point(-104.5, 39.5)
+ writer.record("06730500", 2.5)
+
+
+oracle = {
+ "holes": write("holes", holes),
+ "multipart": write("multipart", multipart),
+ "points": write("points", points),
+}
+print(json.dumps(oracle, indent=2, sort_keys=True))
diff --git a/reactapp/__tests__/utilities/fixtures/shapefile/holes.dbf b/reactapp/__tests__/utilities/fixtures/shapefile/holes.dbf
new file mode 100644
index 0000000000000000000000000000000000000000..121a3ec248d0f012b3c26f398937397719e13cf1
GIT binary patch
literal 150
zcmZRsjOFa?sBz|Yaw6)NfsqBYQzI0m^o1_yfk0)_oRQasG43V0Qq5{onQ
X6v{J8G88iMb5a%X14~0a0|Ns9nN1Kr
literal 0
HcmV?d00001
diff --git a/reactapp/__tests__/utilities/fixtures/shapefile/holes.prj b/reactapp/__tests__/utilities/fixtures/shapefile/holes.prj
new file mode 100644
index 00000000..8cf77785
--- /dev/null
+++ b/reactapp/__tests__/utilities/fixtures/shapefile/holes.prj
@@ -0,0 +1 @@
+PROJCS["NAD_1983_Albers",GEOGCS["GCS_North_American_1983",DATUM["D_North_American_1983",SPHEROID["GRS_1980",6378137.0,298.257222101]],PRIMEM["Greenwich",0.0],UNIT["Degree",0.0174532925199433]],PROJECTION["Albers"],PARAMETER["False_Easting",0.0],PARAMETER["False_Northing",0.0],PARAMETER["Central_Meridian",-96.0],PARAMETER["Standard_Parallel_1",29.5],PARAMETER["Standard_Parallel_2",45.5],PARAMETER["Latitude_Of_Origin",23.0],UNIT["Meter",1.0]]
\ No newline at end of file
diff --git a/reactapp/__tests__/utilities/fixtures/shapefile/holes.shp b/reactapp/__tests__/utilities/fixtures/shapefile/holes.shp
new file mode 100644
index 0000000000000000000000000000000000000000..b1b6f66c3301487f6838adf69233245d96520dd1
GIT binary patch
literal 320
zcmZQzQ0HR64i>y%W?*2&E(a7|;Z_ea5(Hp&y%W?*2&E(a8~aDYg`Xq*Z`5{y8cMT}WYK!q>|;Z_ea5(ESsz!Xjz
VB8y3yK=q;1Fu&lU(bbzc002p|3Hty5
literal 0
HcmV?d00001
diff --git a/reactapp/__tests__/utilities/fixtures/shapefile/multipart.shx b/reactapp/__tests__/utilities/fixtures/shapefile/multipart.shx
new file mode 100644
index 0000000000000000000000000000000000000000..3dee151992e81ef0b3f25003e1087559e2b54002
GIT binary patch
literal 108
lcmZQzQ0HR64$NLKGcd4XmjjAgI6$OeG){#e2_qoR0sx$^0^|Sy
literal 0
HcmV?d00001
diff --git a/reactapp/__tests__/utilities/fixtures/shapefile/points.dbf b/reactapp/__tests__/utilities/fixtures/shapefile/points.dbf
new file mode 100644
index 0000000000000000000000000000000000000000..db10e7e27cd832e294cb4738598a5345f2b8b895
GIT binary patch
literal 139
zcmZRsjO5CxK$z}?Z^HQv(&B;gDqct8Xa2o3=$a0>wn`GJHvK!za!UIha)
Zb7KP|0|NypFwrwIg$SA=1da4e4FKO34KV-!
literal 0
HcmV?d00001
diff --git a/reactapp/__tests__/utilities/fixtures/shapefile/points.prj b/reactapp/__tests__/utilities/fixtures/shapefile/points.prj
new file mode 100644
index 00000000..8cf77785
--- /dev/null
+++ b/reactapp/__tests__/utilities/fixtures/shapefile/points.prj
@@ -0,0 +1 @@
+PROJCS["NAD_1983_Albers",GEOGCS["GCS_North_American_1983",DATUM["D_North_American_1983",SPHEROID["GRS_1980",6378137.0,298.257222101]],PRIMEM["Greenwich",0.0],UNIT["Degree",0.0174532925199433]],PROJECTION["Albers"],PARAMETER["False_Easting",0.0],PARAMETER["False_Northing",0.0],PARAMETER["Central_Meridian",-96.0],PARAMETER["Standard_Parallel_1",29.5],PARAMETER["Standard_Parallel_2",45.5],PARAMETER["Latitude_Of_Origin",23.0],UNIT["Meter",1.0]]
\ No newline at end of file
diff --git a/reactapp/__tests__/utilities/fixtures/shapefile/points.shp b/reactapp/__tests__/utilities/fixtures/shapefile/points.shp
new file mode 100644
index 0000000000000000000000000000000000000000..329a6cce14de48c7f027f9a880ac3fce9f6462b6
GIT binary patch
literal 156
zcmZQzQ0HR64*Xs)GcYj1} components Buffers keyed by extension.
+ * @param {{fallbackProjection?: string}} [options] `fallbackProjection` is the
+ * author-supplied projection, used only when the source carries no .prj.
+ * @returns {Promise<{featureCollection: object, projectionCode: string}
+ * |{error: {stage: string, reason: string, detail: string}}>}
+ */
+export async function interpretShapefile(
+ components,
+ { fallbackProjection } = {},
+) {
+ if (!components?.shp) {
+ return {
+ error: {
+ stage: "parse",
+ reason: "no_geometry",
+ detail: "The source contained no .shp geometry to read.",
+ },
+ };
+ }
+
+ const projection = resolveProjection(components.prj, fallbackProjection);
+ if (projection.error) return projection;
+
+ // Loaded lazily so the parser stays out of the main bundle, matching how the
+ // GeoTIFF reader is pulled in. The browser build is named explicitly: the
+ // package resolves to a Node build under the test runner and a browser build
+ // under the bundler, and the fidelity guarantees below are only worth anything
+ // if they were measured against the artifact that actually ships.
+ const { read } = await import("shapefile/dist/shapefile.js");
+
+ let collection;
+ try {
+ collection = await read(
+ toArrayBuffer(components.shp),
+ components.dbf ? toArrayBuffer(components.dbf) : undefined,
+ );
+ } catch (error) {
+ return {
+ error: {
+ stage: "parse",
+ reason: "unreadable_geometry",
+ detail: `The shapefile geometry could not be read: ${error.message}`,
+ },
+ };
+ }
+
+ // Shapefile encodes a polygon's interior rings by winding direction with no
+ // parent pointer, so the ring order a parser produces is the only record of
+ // which ring is a hole. Normalizing to the GeoJSON spec's winding is cheap
+ // insurance: rendering keys off ring order rather than direction, but anything
+ // downstream that reads the geometry as spec GeoJSON gets what it expects.
+ const { default: rewind } = await import("@mapbox/geojson-rewind");
+ rewind(collection);
+
+ collection.crs = {
+ type: "name",
+ properties: { name: projection.code },
+ };
+
+ return { featureCollection: collection, projectionCode: projection.code };
+}
+
+// The parser reads from an ArrayBuffer. A component buffer may be a view over a
+// larger allocation, so slice to its own bounds rather than handing over the
+// whole backing store.
+function toArrayBuffer(bytes) {
+ return bytes.buffer.slice(
+ bytes.byteOffset,
+ bytes.byteOffset + bytes.byteLength,
+ );
+}
+
+// Absence and failure are different inputs here, and keeping them apart is the
+// point. A .prj that is genuinely missing falls back to what the author
+// supplied; a .prj that failed to arrive was already reported upstream and never
+// reaches this function, because silently substituting a fallback for it would
+// draw the features somewhere else with no error at all.
+function resolveProjection(prjBytes, fallbackProjection) {
+ if (prjBytes) {
+ // Decoded with fflate rather than TextDecoder: this runs in the browser and
+ // under the test runner, and one of those has no TextDecoder.
+ const wkt = strFromU8(prjBytes).trim();
+ const registered = registerProjectionFromWkt(wkt);
+ if (registered.error) {
+ return {
+ error: {
+ stage: "parse",
+ reason: "unresolvable_projection",
+ detail: registered.error.detail,
+ },
+ };
+ }
+ return { code: registered.code };
+ }
+
+ if (fallbackProjection) {
+ const resolved = ensureProjection(fallbackProjection);
+ if (!resolved) {
+ return {
+ error: {
+ stage: "parse",
+ reason: "unresolvable_projection",
+ detail: `The projection "${fallbackProjection}" could not be resolved. Supply a coordinate system this map recognises, or a WKT definition.`,
+ },
+ };
+ }
+ return { code: fallbackProjection };
+ }
+
+ return {
+ error: {
+ stage: "parse",
+ reason: "missing_projection",
+ detail:
+ "The shapefile carries no .prj and no projection was supplied, so its coordinates cannot be placed.",
+ },
+ };
+}
From aaf438367a4a49d3dc7e1711408daced5e1e7019 Mon Sep 17 00:00:00 2001
From: Corey Krewson
Date: Tue, 25 Aug 2026 10:18:32 -0700
Subject: [PATCH 04/18] refactor(map): share the feature-collection read and
the load-status vocabulary
Extracts `readFeatureCollection` from `swapVectorLayerFeatures` so the
shapefile source's loader can read a collection the same way, and adds one
module for the names the two async vector paths share.
The loader cannot call the swap function itself: that clears every feature
already on the source, whereas a loader is additive inside its success
callback. Worth recording alongside it -- clearing a source does *not* reset
OpenLayers' loaded-extent bookkeeping, so it is not a usable retry primitive.
`refresh()` is the one that works.
Taking the projection as a per-call argument rather than baking it in when the
source is built is what lets a loader read features against the view as it
stands at insertion, instead of as it stood when its fetch began. Covered by a
test that reads the same collection into two projections.
The status vocabulary carries one judgment: retry is offered only for
fetch-stage failures. A missing projection, an unresolvable coordinate system,
a malformed component and a source over the size ceiling all need the author
to change something, so a retry button for them invites a viewer to
re-download megabytes and fail identically. The plugin path already gates its
own retry by failure kind for the same reason.
The two paths stay separate by design -- one pushes on its own schedule, the
other is pulled by OpenLayers when a layer renders -- so this shares their
names rather than their lifecycle.
Co-Authored-By: Claude Opus 5 (1M context)
---
.../components/map/layerStatus.test.js | 73 +++++++++++++++++++
.../components/map/utilities.test.js | 59 +++++++++++++++
reactapp/components/map/layerStatus.js | 72 ++++++++++++++++++
reactapp/components/map/utilities.js | 27 ++++++-
4 files changed, 227 insertions(+), 4 deletions(-)
create mode 100644 reactapp/__tests__/components/map/layerStatus.test.js
create mode 100644 reactapp/components/map/layerStatus.js
diff --git a/reactapp/__tests__/components/map/layerStatus.test.js b/reactapp/__tests__/components/map/layerStatus.test.js
new file mode 100644
index 00000000..3ab324b4
--- /dev/null
+++ b/reactapp/__tests__/components/map/layerStatus.test.js
@@ -0,0 +1,73 @@
+import {
+ CANCEL_REASON,
+ ERROR_KIND,
+ isRetryable,
+ errorKindFor,
+} from "components/map/layerStatus";
+
+describe("cancel reasons", () => {
+ it("names the three reasons a load stops", () => {
+ expect(Object.values(CANCEL_REASON).sort()).toEqual([
+ "removed",
+ "superseded",
+ "unmount",
+ ]);
+ });
+});
+
+describe("isRetryable", () => {
+ it("offers retry only for a fetch-stage failure", () => {
+ // Re-running the same request can only help when the request itself was the
+ // problem. Offering it for the others invites a viewer to re-download
+ // megabytes and fail identically.
+ expect(isRetryable(ERROR_KIND.FETCH)).toBe(true);
+ expect(isRetryable(ERROR_KIND.PARSE)).toBe(false);
+ expect(isRetryable(ERROR_KIND.TOO_LARGE)).toBe(false);
+ expect(isRetryable(ERROR_KIND.PROJECTION)).toBe(false);
+ expect(isRetryable(ERROR_KIND.UNAVAILABLE)).toBe(false);
+ });
+
+ it("does not offer retry for an unknown kind", () => {
+ expect(isRetryable(undefined)).toBe(false);
+ expect(isRetryable("something-else")).toBe(false);
+ });
+});
+
+describe("errorKindFor", () => {
+ it.each([
+ [{ stage: "fetch", reason: "unreachable" }, ERROR_KIND.FETCH],
+ [{ stage: "fetch", reason: "unsupported_scheme" }, ERROR_KIND.FETCH],
+ [{ stage: "fetch", reason: "component_status" }, ERROR_KIND.FETCH],
+ [{ stage: "parse", reason: "unreadable_archive" }, ERROR_KIND.PARSE],
+ [{ stage: "parse", reason: "wrong_content_type" }, ERROR_KIND.PARSE],
+ [{ stage: "parse", reason: "unreadable_geometry" }, ERROR_KIND.PARSE],
+ [{ stage: "parse", reason: "ambiguous_archive" }, ERROR_KIND.PARSE],
+ ])("maps %o to %s", (failure, expected) => {
+ expect(errorKindFor(failure)).toBe(expected);
+ });
+
+ it("gives the size ceiling its own kind regardless of stage", () => {
+ // The pipeline reports this on the fetch stage, but a viewer must not be
+ // offered a retry for it.
+ expect(errorKindFor({ stage: "fetch", reason: "too_large" })).toBe(
+ ERROR_KIND.TOO_LARGE,
+ );
+ expect(
+ isRetryable(errorKindFor({ stage: "fetch", reason: "too_large" })),
+ ).toBe(false);
+ });
+
+ it.each(["missing_projection", "unresolvable_projection"])(
+ "gives %s the projection kind so retry is withheld",
+ (reason) => {
+ const kind = errorKindFor({ stage: "parse", reason });
+ expect(kind).toBe(ERROR_KIND.PROJECTION);
+ expect(isRetryable(kind)).toBe(false);
+ },
+ );
+
+ it("defaults to the fetch kind for an unrecognised failure", () => {
+ expect(errorKindFor(undefined)).toBe(ERROR_KIND.FETCH);
+ expect(errorKindFor({})).toBe(ERROR_KIND.FETCH);
+ });
+});
diff --git a/reactapp/__tests__/components/map/utilities.test.js b/reactapp/__tests__/components/map/utilities.test.js
index 4528bad6..6d4887f1 100644
--- a/reactapp/__tests__/components/map/utilities.test.js
+++ b/reactapp/__tests__/components/map/utilities.test.js
@@ -1,4 +1,5 @@
import {
+ readFeatureCollection,
reprojectVectorFeatures,
createMarkerLayer,
createHighlightLayer,
@@ -4188,3 +4189,61 @@ describe("reprojectVectorFeatures", () => {
expect(Math.abs(y - SANTA_INES_3857[1])).toBeLessThan(1);
});
});
+
+describe("readFeatureCollection", () => {
+ const collection = {
+ type: "FeatureCollection",
+ crs: { type: "name", properties: { name: "EPSG:4326" } },
+ features: [
+ {
+ type: "Feature",
+ properties: { NAME: "one" },
+ geometry: { type: "Point", coordinates: [-105, 40] },
+ },
+ ],
+ };
+
+ it("reads a collection into the projection the caller names", () => {
+ const features = readFeatureCollection(collection, "EPSG:3857");
+ expect(features).toHaveLength(1);
+ const [x, y] = features[0].getGeometry().getCoordinates();
+ // Web Mercator metres, not degrees.
+ expect(Math.abs(x)).toBeGreaterThan(1e6);
+ expect(Math.abs(y)).toBeGreaterThan(1e6);
+ expect(features[0].get("NAME")).toBe("one");
+ });
+
+ it("reads into a different projection on a later call", () => {
+ // The projection is a per-call argument, not baked in when the source was
+ // built -- which is what lets a loader read against the view as it stands at
+ // insertion rather than when its fetch began.
+ const asDegrees = readFeatureCollection(collection, "EPSG:4326");
+ const [x] = asDegrees[0].getGeometry().getCoordinates();
+ expect(x).toBeCloseTo(-105, 6);
+ });
+
+ it("defaults the data projection to EPSG:4326 when the collection names none", () => {
+ const { crs, ...withoutCrs } = collection;
+ expect(crs).toBeTruthy();
+ const features = readFeatureCollection(withoutCrs, "EPSG:4326");
+ expect(features[0].getGeometry().getCoordinates()[0]).toBeCloseTo(-105, 6);
+ });
+
+ it("honours a non-default crs on the collection", () => {
+ const mercator = {
+ ...collection,
+ crs: { type: "name", properties: { name: "EPSG:3857" } },
+ features: [
+ {
+ type: "Feature",
+ properties: {},
+ geometry: { type: "Point", coordinates: [-11688546.53, 4865942.28] },
+ },
+ ],
+ };
+ const features = readFeatureCollection(mercator, "EPSG:4326");
+ const [x, y] = features[0].getGeometry().getCoordinates();
+ expect(x).toBeCloseTo(-105, 1);
+ expect(y).toBeCloseTo(40, 1);
+ });
+});
diff --git a/reactapp/components/map/layerStatus.js b/reactapp/components/map/layerStatus.js
new file mode 100644
index 00000000..8a57eba9
--- /dev/null
+++ b/reactapp/components/map/layerStatus.js
@@ -0,0 +1,72 @@
+// Vocabulary shared by the two vector paths that load features asynchronously:
+// the plugin-layer fetcher and the shapefile source's loader.
+//
+// They are deliberately not unified -- one pushes on its own schedule and paints
+// into a preserved layer, the other is pulled by OpenLayers only when a layer is
+// mounted and rendering, and forcing one abstraction over both would mean
+// parameterizing five axes for two implementations. What they do share is these
+// names. Keeping them in one place turns a future divergence into a visible edit
+// rather than two string literals drifting apart.
+
+/** Why an in-flight load stopped. */
+export const CANCEL_REASON = {
+ // A newer load for the same layer started.
+ SUPERSEDED: "superseded",
+ // The layer was removed from the map.
+ REMOVED: "removed",
+ // The map itself went away.
+ UNMOUNT: "unmount",
+};
+
+/**
+ * What kind of failure a layer is in.
+ *
+ * The distinction that carries weight is whether re-running the same request
+ * could succeed. A fetch-stage failure might: the host could come back, a
+ * signature could be refreshed. The rest cannot -- a missing projection, an
+ * unresolvable coordinate system, a malformed component and a source over the
+ * size ceiling all need the author to change something, so offering a viewer a
+ * retry button for them invites them to re-download megabytes to fail the same
+ * way.
+ */
+export const ERROR_KIND = {
+ FETCH: "fetch",
+ PARSE: "parse",
+ TOO_LARGE: "too_large",
+ PROJECTION: "projection",
+ // The plugin path's own kind, for a layer whose plugin is not installed.
+ UNAVAILABLE: "unavailable",
+};
+
+/** Failure kinds where re-running the same request could plausibly succeed. */
+const RETRYABLE = [ERROR_KIND.FETCH];
+
+/**
+ * Whether a retry affordance should be offered for a failure of this kind.
+ *
+ * @param {string} kind One of ERROR_KIND.
+ * @returns {boolean}
+ */
+export function isRetryable(kind) {
+ return RETRYABLE.includes(kind);
+}
+
+/**
+ * Map a typed failure from the shapefile pipeline onto a status error kind.
+ *
+ * The pipeline reports a stage and a reason; the status surface cares about
+ * whether the failure is worth retrying and what to call it.
+ *
+ * @param {{stage?: string, reason?: string}} failure
+ * @returns {string} One of ERROR_KIND.
+ */
+export function errorKindFor(failure) {
+ if (failure?.reason === "too_large") return ERROR_KIND.TOO_LARGE;
+ if (
+ failure?.reason === "missing_projection" ||
+ failure?.reason === "unresolvable_projection"
+ ) {
+ return ERROR_KIND.PROJECTION;
+ }
+ return failure?.stage === "parse" ? ERROR_KIND.PARSE : ERROR_KIND.FETCH;
+}
diff --git a/reactapp/components/map/utilities.js b/reactapp/components/map/utilities.js
index ef331413..1ca7b86c 100644
--- a/reactapp/components/map/utilities.js
+++ b/reactapp/components/map/utilities.js
@@ -333,13 +333,32 @@ export function swapVectorLayerFeatures(
return;
}
+ source.addFeatures(readFeatureCollection(featureCollection, mapProjection));
+}
+
+/**
+ * Parse a GeoJSON FeatureCollection into OpenLayers features.
+ *
+ * `dataProjection` comes from the collection's own `crs`, defaulting to
+ * EPSG:4326 when it carries none, and `featureProjection` is the map's -- so the
+ * caller decides which projection the features land in, at the moment it calls.
+ *
+ * Shared by the swap above and by the shapefile source's loader. The loader must
+ * not call `swapVectorLayerFeatures` itself: that clears every feature already
+ * on the source, whereas a loader is additive inside its success callback.
+ * (Clearing a source does not reset OpenLayers' loaded-extent bookkeeping
+ * either, so it is not a retry primitive -- `refresh()` is.)
+ *
+ * @param {object} featureCollection A GeoJSON FeatureCollection.
+ * @param {string} mapProjection Projection code to read the features into.
+ * @returns {Array}
+ */
+export function readFeatureCollection(featureCollection, mapProjection) {
const crsName = featureCollection?.crs?.properties?.name;
- const dataProjection = crsName || "EPSG:4326";
- const features = new GeoJSONFormat().readFeatures(featureCollection, {
- dataProjection,
+ return new GeoJSONFormat().readFeatures(featureCollection, {
+ dataProjection: crsName || "EPSG:4326",
featureProjection: mapProjection,
});
- source.addFeatures(features);
}
/**
From 8480564c4d9ed0f7d4926b2e421829830aa00531 Mon Sep 17 00:00:00 2001
From: Corey Krewson
Date: Tue, 25 Aug 2026 10:28:53 -0700
Subject: [PATCH 05/18] feat(map): add Shapefile as a selectable map layer
source
Registers the type end to end: the authoring registry, the module mapping,
both dispatch branches in moduleLoader, and an explicit layer-type guard.
Features load through OpenLayers' own loader hook rather than being fetched
ahead of construction. That is what makes the projection requirement
expressible at all -- moduleLoader receives a projection string captured at
task start, but the loader is handed the live one when it runs, and its
success/failure callbacks drive the load-event triple. It also means the
loader is not called until the layer is actually mounted and rendering.
The projection is read again at the moment features are inserted, not when the
load began. A shapefile is the slowest-loading vector source in the app, so it
is the one most exposed to a sibling raster's auto-fit changing the view
mid-load -- features parsed into the outgoing projection are drawn thousands of
kilometres off screen while still reporting the right feature count. Covered by
a test that deliberately makes the construction-time and invocation-time
projections both wrong.
The dispatch for client-loading types is duplicated across the module-cache
path and the post-import path, so both branches are wired and a test builds two
shapefile sources to exercise each.
One documented controller object on the source carries abort, status, error and
reset, rather than three loose properties two modules discover by reaching into
each other. Reset goes through `refresh()`, and the test asserts the loader ran
a second time -- clearing the loaded extent alone leaves it un-invoked, which is
how a retry button ends up doing nothing while its test passes.
No layerId is assigned. Status lives on the source object, so a torn-down layer
takes its status with it and a rebuilt one starts idle -- there is no external
keyspace to invalidate, and none of the reused-identity hazards apply.
The projection field takes a WKT or proj4 definition as well as a code, since a
.prj-less shapefile in a CRS the table does not cover has no other authorable
path. Handling that turned up a real gap: wkt-parser cannot read a proj4
string, so the definition is only parsed as WKT when it is shaped like WKT, and
the probe reads the definition back from proj4 instead. `registerProjectionFromWkt`
is renamed `registerProjectionDefinition` to match what it now accepts.
Also fixes a latent flake of my own making: acquire.test.js assigned
global.fetch without restoring it, and Jest reuses a worker across files.
Co-Authored-By: Claude Opus 5 (1M context)
---
.../components/map/projections.test.js | 28 +-
.../components/map/shapefile/acquire.test.js | 10 +
.../components/map/shapefile/index.test.js | 54 ++++
.../components/map/shapefileSource.test.js | 292 ++++++++++++++++++
.../components/map/utilities.test.js | 26 ++
.../modals/MapLayer/MapLayer.test.js | 15 +
reactapp/__tests__/utilities/constants.js | 20 ++
reactapp/components/map/Map.js | 12 +-
reactapp/components/map/ModuleLoader.js | 118 ++++++-
reactapp/components/map/projections.js | 81 +++--
reactapp/components/map/shapefile/index.js | 30 +-
reactapp/components/map/utilities.js | 15 +
.../components/modals/MapLayer/MapLayer.js | 6 +
13 files changed, 660 insertions(+), 47 deletions(-)
create mode 100644 reactapp/__tests__/components/map/shapefileSource.test.js
diff --git a/reactapp/__tests__/components/map/projections.test.js b/reactapp/__tests__/components/map/projections.test.js
index 560daeaa..04eba8ec 100644
--- a/reactapp/__tests__/components/map/projections.test.js
+++ b/reactapp/__tests__/components/map/projections.test.js
@@ -4,7 +4,7 @@ import {
PROJECTION_TABLE,
INITIAL_CODES,
ensureProjection,
- registerProjectionFromWkt,
+ registerProjectionDefinition,
} from "components/map/projections";
// A projected CRS with no AUTHORITY node, which is how ESRI writes .prj files.
@@ -101,9 +101,9 @@ describe("ensureProjection", () => {
});
});
-describe("registerProjectionFromWkt", () => {
+describe("registerProjectionDefinition", () => {
it("registers WKT with no authority node and transforms with it", () => {
- const result = registerProjectionFromWkt(ESRI_ALBERS_NO_AUTHORITY);
+ const result = registerProjectionDefinition(ESRI_ALBERS_NO_AUTHORITY);
expect(result.error).toBeUndefined();
expect(result.code).toMatch(/^WKT:/);
expect(getProjection(result.code)).toBeTruthy();
@@ -118,7 +118,7 @@ describe("registerProjectionFromWkt", () => {
it("reuses an already-resolvable code instead of registering the layer's copy", () => {
const before = proj4("EPSG:4326", "EPSG:5070", [-105, 40]);
- const result = registerProjectionFromWkt(ALBERS_5070_WRONG_PARAMS);
+ const result = registerProjectionDefinition(ALBERS_5070_WRONG_PARAMS);
expect(result.code).toBe("EPSG:5070");
// The seeded definition survives and the layer's differing parameters are
@@ -134,7 +134,7 @@ describe("registerProjectionFromWkt", () => {
expect(getProjection(claimed)).toBeFalsy();
const wkt = withAuthority(ESRI_ALBERS_NO_AUTHORITY, claimed);
- const result = registerProjectionFromWkt(wkt);
+ const result = registerProjectionDefinition(wkt);
// Guard against this passing vacuously: the fixture must actually carry a
// top-level authority, or the module would fall through to a synthetic code
@@ -148,13 +148,13 @@ describe("registerProjectionFromWkt", () => {
});
it("returns the same code for the same WKT without re-registering", () => {
- const first = registerProjectionFromWkt(ESRI_ALBERS_NO_AUTHORITY);
- const second = registerProjectionFromWkt(ESRI_ALBERS_NO_AUTHORITY);
+ const first = registerProjectionDefinition(ESRI_ALBERS_NO_AUTHORITY);
+ const second = registerProjectionDefinition(ESRI_ALBERS_NO_AUTHORITY);
expect(second.code).toBe(first.code);
});
it("reports an unparsable definition and names what failed", () => {
- const result = registerProjectionFromWkt('PROJCS["broken",GARBAGE[');
+ const result = registerProjectionDefinition('PROJCS["broken",GARBAGE[');
expect(result.code).toBeUndefined();
expect(result.error.reason).toBe("unparsable");
expect(result.error.detail).toMatch(/could not be parsed/);
@@ -163,7 +163,7 @@ describe("registerProjectionFromWkt", () => {
it("reports an unsupported projection method by name", () => {
// This one parses cleanly and only fails at transform time, so the message
// has to come from validating the transform rather than from the parser.
- const result = registerProjectionFromWkt(UNSUPPORTED_METHOD);
+ const result = registerProjectionDefinition(UNSUPPORTED_METHOD);
expect(result.code).toBeUndefined();
expect(result.error.reason).toBe("unsupported");
expect(result.error.detail).toContain("Totally_Not_A_Real_Projection");
@@ -173,7 +173,7 @@ describe("registerProjectionFromWkt", () => {
// proj4 implements the ESRI spelling of Albers but not this one, and the
// failure is silent: non-finite coordinates, not an exception. Caught by
// probing the definition at its own centre before registering it.
- const result = registerProjectionFromWkt(OGC_ALBERS);
+ const result = registerProjectionDefinition(OGC_ALBERS);
expect(result.code).toBeUndefined();
expect(result.error.reason).toBe("unsupported");
expect(result.error.detail).toContain("Albers_Conic_Equal_Area");
@@ -183,14 +183,14 @@ describe("registerProjectionFromWkt", () => {
// A rejected definition must not stay in proj4's registry: register()
// constructs a transform for every pair of registered codes, so one unusable
// definition would make it throw and take working projections down with it.
- registerProjectionFromWkt(UNSUPPORTED_METHOD);
- const after = registerProjectionFromWkt(ESRI_ALBERS_NO_AUTHORITY);
+ registerProjectionDefinition(UNSUPPORTED_METHOD);
+ const after = registerProjectionDefinition(ESRI_ALBERS_NO_AUTHORITY);
expect(after.error).toBeUndefined();
expect(getProjection("EPSG:5070")).toBeTruthy();
});
it("reports an empty definition rather than throwing", () => {
- expect(registerProjectionFromWkt("").error.reason).toBe("empty");
- expect(registerProjectionFromWkt(undefined).error.reason).toBe("empty");
+ expect(registerProjectionDefinition("").error.reason).toBe("empty");
+ expect(registerProjectionDefinition(undefined).error.reason).toBe("empty");
});
});
diff --git a/reactapp/__tests__/components/map/shapefile/acquire.test.js b/reactapp/__tests__/components/map/shapefile/acquire.test.js
index f165f7f2..922c78c4 100644
--- a/reactapp/__tests__/components/map/shapefile/acquire.test.js
+++ b/reactapp/__tests__/components/map/shapefile/acquire.test.js
@@ -31,13 +31,23 @@ function respond({ status = 200, contentType = "application/zip", body } = {}) {
}
let fetchMock;
+let originalFetch;
beforeEach(() => {
clearComponentCache();
+ originalFetch = global.fetch;
fetchMock = jest.fn();
global.fetch = fetchMock;
});
+// Restored rather than left assigned. Jest reuses a worker process across test
+// files, so a fetch mock left on the global leaks into whichever file that
+// worker picks up next -- which shows up as an unrelated suite failing only in
+// certain run orders.
+afterEach(() => {
+ global.fetch = originalFetch;
+});
+
describe("acquireComponents — validation happens before any request", () => {
it.each([
"data:application/zip;base64,UEsDBA==",
diff --git a/reactapp/__tests__/components/map/shapefile/index.test.js b/reactapp/__tests__/components/map/shapefile/index.test.js
index 82d85fa6..13e291f3 100644
--- a/reactapp/__tests__/components/map/shapefile/index.test.js
+++ b/reactapp/__tests__/components/map/shapefile/index.test.js
@@ -207,3 +207,57 @@ describe("interpretShapefile — failure paths", () => {
);
});
});
+
+describe("interpretShapefile — projection field accepts a definition", () => {
+ const ESRI_ALBERS = `PROJCS["NAD_1983_Albers",GEOGCS["GCS_North_American_1983",DATUM["D_North_American_1983",SPHEROID["GRS_1980",6378137.0,298.257222101]],PRIMEM["Greenwich",0.0],UNIT["Degree",0.0174532925199433]],PROJECTION["Albers"],PARAMETER["False_Easting",0.0],PARAMETER["False_Northing",0.0],PARAMETER["Central_Meridian",-96.0],PARAMETER["Standard_Parallel_1",29.5],PARAMETER["Standard_Parallel_2",45.5],PARAMETER["Latitude_Of_Origin",23.0],UNIT["Meter",1.0]]`;
+
+ it("accepts WKT as the fallback when there is no .prj", async () => {
+ // Without this, a .prj-less shapefile in a CRS the table does not cover has
+ // no authorable path at all -- there is no code to type that resolves.
+ const components = load("holes");
+ delete components.prj;
+
+ const result = await interpretShapefile(components, {
+ fallbackProjection: ESRI_ALBERS,
+ });
+
+ expect(result.error).toBeUndefined();
+ expect(result.projectionCode).toMatch(/^WKT:/);
+ });
+
+ it("accepts a proj4 definition as the fallback", async () => {
+ const components = load("holes");
+ delete components.prj;
+
+ const result = await interpretShapefile(components, {
+ fallbackProjection:
+ "+proj=aea +lat_0=23 +lon_0=-96 +lat_1=29.5 +lat_2=45.5 +x_0=0 +y_0=0 +datum=NAD83 +units=m +no_defs",
+ });
+
+ expect(result.error).toBeUndefined();
+ expect(result.projectionCode).toBeTruthy();
+ });
+
+ it("reports a malformed definition as unparsable rather than as an unknown code", async () => {
+ const components = load("holes");
+ delete components.prj;
+
+ const result = await interpretShapefile(components, {
+ fallbackProjection: 'PROJCS["broken",GARBAGE[',
+ });
+
+ expect(result.error.reason).toBe("unresolvable_projection");
+ expect(result.error.detail).toMatch(/could not be parsed/);
+ });
+
+ it("still treats a short token as a code", async () => {
+ const components = load("holes");
+ delete components.prj;
+
+ const result = await interpretShapefile(components, {
+ fallbackProjection: "EPSG:5070",
+ });
+
+ expect(result.projectionCode).toBe("EPSG:5070");
+ });
+});
diff --git a/reactapp/__tests__/components/map/shapefileSource.test.js b/reactapp/__tests__/components/map/shapefileSource.test.js
new file mode 100644
index 00000000..019de6f7
--- /dev/null
+++ b/reactapp/__tests__/components/map/shapefileSource.test.js
@@ -0,0 +1,292 @@
+import { get as getProjection } from "ol/proj.js";
+import VectorSource from "ol/source/Vector.js";
+import moduleLoader, { loadShapefile } from "components/map/ModuleLoader";
+import { acquireComponents } from "components/map/shapefile/acquire";
+import { interpretShapefile } from "components/map/shapefile/index";
+
+// The pipeline is covered by its own suites; what matters here is the wiring --
+// which projection features land in, what drives the load events, and what the
+// controller exposes.
+jest.mock("components/map/shapefile/acquire", () => ({
+ acquireComponents: jest.fn(),
+}));
+jest.mock("components/map/shapefile/index", () => ({
+ interpretShapefile: jest.fn(),
+}));
+
+const FULL_EXTENT = [-Infinity, -Infinity, Infinity, Infinity];
+
+// A single point at -105, 40 in degrees, so the projection features are read
+// into is observable from the resulting coordinates.
+const COLLECTION = {
+ type: "FeatureCollection",
+ crs: { type: "name", properties: { name: "EPSG:4326" } },
+ features: [
+ {
+ type: "Feature",
+ properties: { NAME: "basin" },
+ geometry: { type: "Point", coordinates: [-105, 40] },
+ },
+ ],
+};
+
+function config(props = {}) {
+ return {
+ type: "Shapefile",
+ props: { url: "https://example.org/basins.zip", ...props },
+ };
+}
+
+function drive(source, projectionCode = "EPSG:3857") {
+ source.loadFeatures(FULL_EXTENT, 1, getProjection(projectionCode));
+}
+
+beforeEach(() => {
+ acquireComponents.mockReset();
+ interpretShapefile.mockReset();
+ acquireComponents.mockResolvedValue({
+ components: { shp: new Uint8Array() },
+ });
+ interpretShapefile.mockResolvedValue({
+ featureCollection: COLLECTION,
+ projectionCode: "EPSG:4326",
+ });
+});
+
+describe("loadShapefile — construction", () => {
+ it("builds a vector source that loads through a loader, not a url", () => {
+ const source = loadShapefile(config(), "EPSG:3857");
+ expect(source).toBeInstanceOf(VectorSource);
+ // A `url` would hand fetching to OpenLayers, which cannot decompress an
+ // archive or enforce a byte ceiling.
+ expect(source.getUrl()).toBeUndefined();
+ });
+
+ it("throws the empty sentinel for a source with no url", () => {
+ // Mirrors the GeoTIFF sentinel: a half-authored source stays silent rather
+ // than painting a failure after every keystroke.
+ expect(() => loadShapefile({ type: "Shapefile", props: {} })).toThrow(
+ "ShapefileEmptySources",
+ );
+ expect(() => loadShapefile({ type: "Shapefile" })).toThrow(
+ "ShapefileEmptySources",
+ );
+ });
+
+ it("exposes one controller carrying abort, status, error and reset", () => {
+ const controller = loadShapefile(config(), "EPSG:3857").get(
+ "shapefileController",
+ );
+ expect(typeof controller.abort).toBe("function");
+ expect(typeof controller.getStatus).toBe("function");
+ expect(typeof controller.getError).toBe("function");
+ expect(typeof controller.reset).toBe("function");
+ expect(controller.getStatus()).toBe("idle");
+ });
+});
+
+describe("loadShapefile — projection at insertion", () => {
+ it("reads features against the projection supplied when they are inserted", async () => {
+ // The construction-time projection is deliberately wrong here. A shapefile
+ // is the slowest-loading vector source in the app, so it is the one most
+ // exposed to a sibling raster's auto-fit changing the view mid-load --
+ // features parsed into the outgoing projection are drawn far off screen
+ // while still reporting the right count.
+ let current = "EPSG:4326";
+ const source = loadShapefile(config(), "EPSG:3857", () => current);
+
+ current = "EPSG:3857";
+ drive(source, "EPSG:4326");
+ await new Promise(process.nextTick);
+
+ const [x, y] = source.getFeatures()[0].getGeometry().getCoordinates();
+ // Web Mercator metres, from the getter -- not the degrees either the
+ // construction argument or the loader argument would have given.
+ expect(Math.abs(x)).toBeGreaterThan(1e6);
+ expect(Math.abs(y)).toBeGreaterThan(1e6);
+ });
+
+ it("falls back to the loader's own projection when no getter is supplied", async () => {
+ const source = loadShapefile(config(), "EPSG:3857");
+ drive(source, "EPSG:4326");
+ await new Promise(process.nextTick);
+
+ const [x] = source.getFeatures()[0].getGeometry().getCoordinates();
+ expect(x).toBeCloseTo(-105, 6);
+ });
+});
+
+describe("loadShapefile — load events and status", () => {
+ it("fires featuresloadend and reports ready on success", async () => {
+ const source = loadShapefile(config(), "EPSG:3857");
+ const started = jest.fn();
+ const ended = jest.fn();
+ source.on("featuresloadstart", started);
+ source.on("featuresloadend", ended);
+
+ drive(source);
+ await new Promise(process.nextTick);
+
+ expect(started).toHaveBeenCalled();
+ expect(ended).toHaveBeenCalled();
+ expect(source.get("shapefileController").getStatus()).toBe("ready");
+ expect(source.getFeatures()).toHaveLength(1);
+ });
+
+ it("fires featuresloaderror and keeps the typed failure on an acquisition failure", async () => {
+ acquireComponents.mockResolvedValue({
+ error: { stage: "fetch", reason: "unreachable", detail: "no host" },
+ });
+ const source = loadShapefile(config(), "EPSG:3857");
+ const errored = jest.fn();
+ source.on("featuresloaderror", errored);
+
+ drive(source);
+ await new Promise(process.nextTick);
+
+ expect(errored).toHaveBeenCalled();
+ const controller = source.get("shapefileController");
+ expect(controller.getStatus()).toBe("error");
+ // featuresloaderror carries no payload, so the typed failure has to travel
+ // on the source for anything to report a real message.
+ expect(controller.getError()).toEqual({
+ stage: "fetch",
+ reason: "unreachable",
+ detail: "no host",
+ });
+ expect(source.getFeatures()).toHaveLength(0);
+ });
+
+ it("keeps the typed failure on an interpretation failure", async () => {
+ interpretShapefile.mockResolvedValue({
+ error: {
+ stage: "parse",
+ reason: "missing_projection",
+ detail: "no prj",
+ },
+ });
+ const source = loadShapefile(config(), "EPSG:3857");
+
+ drive(source);
+ await new Promise(process.nextTick);
+
+ expect(source.get("shapefileController").getError().reason).toBe(
+ "missing_projection",
+ );
+ });
+
+ it("returns to idle with no failure when the load is cancelled", async () => {
+ acquireComponents.mockResolvedValue({ cancelled: true });
+ const source = loadShapefile(config(), "EPSG:3857");
+
+ drive(source);
+ await new Promise(process.nextTick);
+
+ const controller = source.get("shapefileController");
+ expect(controller.getStatus()).toBe("idle");
+ expect(controller.getError()).toBeNull();
+ });
+
+ it("passes the author's projection through as the fallback", async () => {
+ const source = loadShapefile(
+ config({ projection: "EPSG:5070" }),
+ "EPSG:3857",
+ );
+ drive(source);
+ await new Promise(process.nextTick);
+
+ expect(interpretShapefile).toHaveBeenCalledWith(expect.anything(), {
+ fallbackProjection: "EPSG:5070",
+ });
+ });
+});
+
+describe("loadShapefile — controller actions", () => {
+ it("aborts an in-flight load through the controller", async () => {
+ let capturedSignal;
+ acquireComponents.mockImplementation((url, { signal }) => {
+ capturedSignal = signal;
+ return new Promise(() => {});
+ });
+ const source = loadShapefile(config(), "EPSG:3857");
+
+ drive(source);
+ await new Promise(process.nextTick);
+ expect(capturedSignal.aborted).toBe(false);
+
+ source.get("shapefileController").abort("removed");
+
+ expect(capturedSignal.aborted).toBe(true);
+ expect(source.get("shapefileController").getStatus()).toBe("idle");
+ });
+
+ it("aborting when nothing is in flight is a no-op", () => {
+ const source = loadShapefile(config(), "EPSG:3857");
+ expect(() =>
+ source.get("shapefileController").abort("removed"),
+ ).not.toThrow();
+ });
+
+ it("reset re-invokes the loader", async () => {
+ // Asserted by invocation count rather than by whether the loaded extent was
+ // cleared: clearing the extent alone leaves the loader un-invoked, so an
+ // extent assertion passes while retry is broken.
+ const source = loadShapefile(config(), "EPSG:3857");
+ drive(source);
+ await new Promise(process.nextTick);
+ expect(acquireComponents).toHaveBeenCalledTimes(1);
+
+ source.get("shapefileController").reset();
+ drive(source);
+ await new Promise(process.nextTick);
+
+ expect(acquireComponents).toHaveBeenCalledTimes(2);
+ expect(source.get("shapefileController").getStatus()).toBe("ready");
+ });
+
+ it("reset clears a previous failure before reloading", async () => {
+ acquireComponents.mockResolvedValueOnce({
+ error: { stage: "fetch", reason: "unreachable", detail: "no host" },
+ });
+ const source = loadShapefile(config(), "EPSG:3857");
+ drive(source);
+ await new Promise(process.nextTick);
+ expect(source.get("shapefileController").getError()).toBeTruthy();
+
+ source.get("shapefileController").reset();
+
+ expect(source.get("shapefileController").getError()).toBeNull();
+ expect(source.get("shapefileController").getStatus()).toBe("idle");
+ });
+});
+
+describe("moduleLoader dispatch", () => {
+ it("builds a shapefile source on the first call and on the second", async () => {
+ // The dispatch for client-loading types is duplicated across the
+ // module-cache path and the post-import path. Missing one means the first
+ // layer of a type works and the second breaks.
+ const first = await moduleLoader(config(), "EPSG:3857");
+ const second = await moduleLoader(config(), "EPSG:3857");
+
+ expect(first).toBeInstanceOf(VectorSource);
+ expect(second).toBeInstanceOf(VectorSource);
+ expect(first.get("shapefileController")).toBeTruthy();
+ expect(second.get("shapefileController")).toBeTruthy();
+ expect(first).not.toBe(second);
+ });
+
+ it("forwards the live projection getter through moduleLoader", async () => {
+ const source = await moduleLoader(config(), "EPSG:3857", () => "EPSG:3857");
+ drive(source, "EPSG:4326");
+ await new Promise(process.nextTick);
+
+ const [x] = source.getFeatures()[0].getGeometry().getCoordinates();
+ expect(Math.abs(x)).toBeGreaterThan(1e6);
+ });
+
+ it("propagates the empty sentinel out of moduleLoader", async () => {
+ await expect(
+ moduleLoader({ type: "Shapefile", props: {} }, "EPSG:3857"),
+ ).rejects.toThrow("ShapefileEmptySources");
+ });
+});
diff --git a/reactapp/__tests__/components/map/utilities.test.js b/reactapp/__tests__/components/map/utilities.test.js
index 6d4887f1..c88d623c 100644
--- a/reactapp/__tests__/components/map/utilities.test.js
+++ b/reactapp/__tests__/components/map/utilities.test.js
@@ -1,4 +1,5 @@
import {
+ sourcePropertiesOptions,
readFeatureCollection,
reprojectVectorFeatures,
createMarkerLayer,
@@ -4247,3 +4248,28 @@ describe("readFeatureCollection", () => {
expect(y).toBeCloseTo(40, 1);
});
});
+
+describe("sourcePropertiesOptions — Shapefile", () => {
+ it("is offered as a source type with url required", () => {
+ // This single object drives the source-type dropdown, the properties table,
+ // and the required-key validation at save.
+ const entry = sourcePropertiesOptions.Shapefile;
+ expect(entry).toBeTruthy();
+ expect(Object.keys(entry.required)).toEqual(["url"]);
+ expect(entry.required.url.placeholder).toMatch(/\.zip|\.shp/);
+ });
+
+ it("offers projection and attributions as optional", () => {
+ const optional = Object.keys(sourcePropertiesOptions.Shapefile.optional);
+ expect(optional).toContain("projection");
+ expect(optional).toContain("attributions");
+ });
+
+ it("tells the author the projection field takes a definition as well as a code", () => {
+ // A .prj-less shapefile in an uncommon CRS has no table entry to name, so
+ // the field has to accept WKT for that case to be authorable at all.
+ expect(
+ sourcePropertiesOptions.Shapefile.optional.projection.placeholder,
+ ).toMatch(/WKT|proj4/i);
+ });
+});
diff --git a/reactapp/__tests__/components/modals/MapLayer/MapLayer.test.js b/reactapp/__tests__/components/modals/MapLayer/MapLayer.test.js
index 7ce90204..834eb4e4 100644
--- a/reactapp/__tests__/components/modals/MapLayer/MapLayer.test.js
+++ b/reactapp/__tests__/components/modals/MapLayer/MapLayer.test.js
@@ -3640,3 +3640,18 @@ ExtentTestComponent.propTypes = {
layerInfo: PropTypes.object,
visualizationRefOverride: PropTypes.object,
};
+
+describe("getLayerType — Shapefile", () => {
+ it("routes Shapefile to a vector layer", () => {
+ expect(getLayerType("Shapefile")).toBe("VectorLayer");
+ });
+
+ it("pins the exact label", () => {
+ // getLayerType routes by substring, so a label variant would silently pick a
+ // different layer class with no error. This documents that the exact string
+ // is load-bearing -- and it is persisted user data besides, so renaming it
+ // would cost a migration over every dashboard.
+ expect(getLayerType("Shapefile Tile")).toBe("TileLayer");
+ expect(getLayerType("Shapefile Vector")).toBe("VectorTileLayer");
+ });
+});
diff --git a/reactapp/__tests__/utilities/constants.js b/reactapp/__tests__/utilities/constants.js
index 8547ccb9..b59970cd 100644
--- a/reactapp/__tests__/utilities/constants.js
+++ b/reactapp/__tests__/utilities/constants.js
@@ -1360,6 +1360,26 @@ export const layerConfigKML = {
},
};
+// Neither the GeoTIFF nor the Zarr source type added a fixture here, which made
+// both harder to test than they needed to be. The URL lives at
+// source.props.url, like every other URL-based source -- GeoJSON's storage
+// outside props is exactly why it needs a special case in nearly every dispatch
+// function.
+export const layerConfigShapefile = {
+ configuration: {
+ type: "VectorLayer",
+ props: {
+ name: "Shapefile Layer",
+ source: {
+ type: "Shapefile",
+ props: {
+ url: "https://example.org/basins.zip",
+ },
+ },
+ },
+ },
+};
+
export const layerConfigGeoJSON = {
configuration: {
type: "VectorLayer",
diff --git a/reactapp/components/map/Map.js b/reactapp/components/map/Map.js
index a374c48d..69e127fc 100644
--- a/reactapp/components/map/Map.js
+++ b/reactapp/components/map/Map.js
@@ -367,6 +367,12 @@ const MapComponent = ({
const newLayer = await moduleLoader(
layerConfig,
map.getView().getProjection().getCode(),
+ // Read again when features are actually inserted. A source with a
+ // long async load -- a shapefile -- can finish after a sibling
+ // raster's auto-fit has already changed the view, and features
+ // parsed into the outgoing projection are drawn far off screen
+ // while still reporting the right count.
+ () => map.getView().getProjection().getCode(),
);
newLayer.set("name", name);
@@ -607,7 +613,11 @@ const MapComponent = ({
}
}
} catch (err) {
- if (err && err.message === "GeoTIFFEmptySources") {
+ if (
+ err &&
+ (err.message === "GeoTIFFEmptySources" ||
+ err.message === "ShapefileEmptySources")
+ ) {
return;
}
console.log(err);
diff --git a/reactapp/components/map/ModuleLoader.js b/reactapp/components/map/ModuleLoader.js
index b34c5c14..8fac355c 100644
--- a/reactapp/components/map/ModuleLoader.js
+++ b/reactapp/components/map/ModuleLoader.js
@@ -26,7 +26,12 @@ import {
defaultDotSpacing,
defaultDotRadius,
} from "components/inputs/RuleEditor.js";
-import { rewriteArcGISExportUrlForAntimeridian } from "components/map/utilities";
+import {
+ rewriteArcGISExportUrlForAntimeridian,
+ readFeatureCollection,
+} from "components/map/utilities";
+import { acquireComponents } from "components/map/shapefile/acquire";
+import { interpretShapefile } from "components/map/shapefile/index";
import {
buildGeoTIFFStyleColor,
buildCategoricalStyleColor,
@@ -313,7 +318,7 @@ export async function applyAutoRamp(layerConfig) {
return layerConfig;
}
-const moduleLoader = async (config, mapProjection) => {
+const moduleLoader = async (config, mapProjection, getMapProjection) => {
if (config.type === "Zarr") {
// Already yields OL's `sources` shape, so it skips the GeoTIFF branch below.
config = zarrSourceToGeoTIFF(config);
@@ -349,6 +354,8 @@ const moduleLoader = async (config, mapProjection) => {
if (moduleCache[type]) {
if (type === "GeoJSON") {
return loadGeoJSON(config, mapProjection);
+ } else if (type === "Shapefile") {
+ return loadShapefile(config, mapProjection, getMapProjection);
} else if (type === "ESRI Feature Service") {
return loadESRIJSON(config);
} else {
@@ -390,6 +397,8 @@ const moduleLoader = async (config, mapProjection) => {
if (type === "GeoJSON") {
return loadGeoJSON(config, mapProjection);
+ } else if (type === "Shapefile") {
+ return loadShapefile(config, mapProjection, getMapProjection);
} else if (type === "ESRI Feature Service") {
return loadESRIJSON(config);
} else {
@@ -506,6 +515,7 @@ const getModuleImporter = (type) => {
WMS: "ol/source/ImageWMS.js",
Raster: "ol/source/Raster.js",
GeoJSON: "ol/format/GeoJSON.js",
+ Shapefile: "ol/source/Vector.js",
KML: "ol/source/Vector.js",
Style: "ol/style/Style.js",
Stroke: "ol/style/Stroke.js",
@@ -535,6 +545,110 @@ const getModuleImporter = (type) => {
return importer;
};
+/**
+ * Build the vector source for a `Shapefile` layer.
+ *
+ * Features load through OpenLayers' own loader hook rather than being fetched
+ * ahead of construction, which buys three things: the loader is handed the live
+ * view projection when it runs, its success/failure callbacks drive the
+ * `featuresloadstart` / `featuresloadend` / `featuresloaderror` events, and it
+ * is not called at all until the layer is actually mounted and rendering.
+ *
+ * `getMapProjection`, when supplied, is read at the moment features are inserted
+ * rather than when the load began. A shapefile is the slowest-loading vector
+ * source in the app, so it is the one most exposed to a sibling raster's auto-fit
+ * changing the view mid-load -- and features parsed into a projection the map has
+ * already left are drawn thousands of kilometres off screen while still reporting
+ * the right feature count.
+ *
+ * The single `shapefileController` set on the source is the whole channel between
+ * this module and the map: abort, status, error and reset. Hanging those on the
+ * source as loose properties would give two modules an undocumented surface each
+ * discovered by reaching into the other's object.
+ */
+export const loadShapefile = (config, mapProjection, getMapProjection) => {
+ const { url, projection: fallbackProjection } = config.props ?? {};
+ // Mirrors the GeoTIFF sentinel: a half-authored source is silent rather than
+ // an error, so typing a URL does not paint a failure after every keystroke.
+ if (!url) throw new Error("ShapefileEmptySources");
+
+ let abortController = null;
+ let status = "idle";
+ let failure = null;
+
+ const source = new VectorSource();
+
+ source.setLoader(async (extent, resolution, projection, success, onError) => {
+ abortController = new AbortController();
+ status = "loading";
+ failure = null;
+
+ const finish = (nextStatus, nextFailure) => {
+ status = nextStatus;
+ failure = nextFailure ?? null;
+ abortController = null;
+ };
+
+ const acquired = await acquireComponents(url, {
+ signal: abortController.signal,
+ });
+ if (acquired.cancelled) {
+ finish("idle");
+ onError?.();
+ return;
+ }
+ if (acquired.error) {
+ finish("error", acquired.error);
+ onError?.();
+ return;
+ }
+
+ const interpreted = await interpretShapefile(acquired.components, {
+ fallbackProjection,
+ });
+ if (interpreted.error) {
+ finish("error", interpreted.error);
+ onError?.();
+ return;
+ }
+
+ // Read against the view as it stands now, not as it stood when the fetch
+ // was issued.
+ const targetProjection =
+ getMapProjection?.() ?? projection?.getCode?.() ?? mapProjection;
+ const features = readFeatureCollection(
+ interpreted.featureCollection,
+ targetProjection,
+ );
+ source.addFeatures(features);
+ finish("ready");
+ success?.(features);
+ });
+
+ source.set("shapefileController", {
+ getStatus: () => status,
+ getError: () => failure,
+ abort: (reason) => {
+ if (abortController) {
+ abortController.abort(reason);
+ abortController = null;
+ status = "idle";
+ }
+ },
+ // `refresh` is the only primitive that actually causes the loader to run
+ // again. Removing the loaded extent alone leaves it un-invoked, because the
+ // renderer short-circuits its frame on an unchanged layer revision -- which
+ // is how a retry button ends up doing nothing while its test passes.
+ reset: () => {
+ status = "idle";
+ failure = null;
+ source.refresh();
+ },
+ });
+
+ return source;
+};
+
const loadGeoJSON = (config, mapProjection) => {
const geojson = config.geojson;
diff --git a/reactapp/components/map/projections.js b/reactapp/components/map/projections.js
index 569e309e..b3d544b2 100644
--- a/reactapp/components/map/projections.js
+++ b/reactapp/components/map/projections.js
@@ -15,7 +15,7 @@ import wktParser from "wkt-parser";
// already be on hand. That is what the table below is for.
//
// 2. Definitions a layer brings with it. A shapefile carries its CRS as WKT in
-// its .prj, so it needs no table entry -- see registerProjectionFromWkt.
+// its .prj, so it needs no table entry -- see registerProjectionDefinition.
//
// The table therefore only has to cover case 1, which is why it is short. A
// survey of the live dashboards found exactly one layer naming a non-native code
@@ -143,12 +143,12 @@ export function ensureProjection(code) {
return getProjection(code);
}
-// Stable, dependency-free hash of the normalized WKT. Two textually different
-// but semantically equivalent definitions hash differently and so register
-// separately; that costs a duplicate registration and nothing else, which is
-// cheaper than trying to canonicalise WKT.
-function wktCode(wkt) {
- const normalized = wkt.replace(/\s+/g, "");
+// Stable, dependency-free hash of the normalized definition. Two textually
+// different but semantically equivalent definitions hash differently and so
+// register separately; that costs a duplicate registration and nothing else,
+// which is cheaper than trying to canonicalise WKT.
+function definitionCode(definition) {
+ const normalized = definition.replace(/\s+/g, "");
let hash = 5381;
for (let i = 0; i < normalized.length; i += 1) {
hash = ((hash << 5) + hash + normalized.charCodeAt(i)) | 0;
@@ -201,31 +201,61 @@ function definitionUsable(code, parsed) {
}
}
+// WKT names its projected or geographic CRS with a bracketed keyword; a proj4
+// string is a run of `+key=value` tokens. Only the former carries an AUTHORITY
+// node worth reading, and only the former can be handed to the WKT parser.
+function isWkt(definition) {
+ return /\b(PROJCS|GEOGCS|PROJCRS|GEOGCRS|GEODCRS)\s*\[/i.test(definition);
+}
+
/**
- * Register a coordinate reference system from a layer's own WKT definition.
+ * Register a coordinate reference system from a definition a layer supplies --
+ * WKT, as found in a shapefile's .prj, or a proj4 string.
*
- * Never overwrites a definition that already resolves. A layer's WKT is
+ * Never overwrites a definition that already resolves. A layer's definition is
* authoritative for that layer's own features, but the projection registry is
* global to the browser session -- so letting one layer's parameters replace a
* code every other layer resolves through would make rendering depend on which
- * dashboard was opened first. When the WKT claims a code that already resolves,
- * the existing definition is reused and nothing is written. Otherwise the
- * definition is registered under a synthetic code, never under the claimed one.
+ * dashboard was opened first. When the definition claims a code that already
+ * resolves, the existing one is reused and nothing is written. Otherwise it is
+ * registered under a synthetic code, never under the claimed one.
*
- * @param {string} wkt WKT definition, typically the contents of a .prj.
+ * @param {string} definition WKT or proj4 definition.
* @returns {{code: string}|{error: {reason: string, detail: string}}} The code to
* read coordinates with, or a failure describing what could not be resolved.
*/
-export function registerProjectionFromWkt(wkt) {
- if (typeof wkt !== "string" || wkt.trim() === "") {
+export function registerProjectionDefinition(definition) {
+ if (typeof definition !== "string" || definition.trim() === "") {
return {
error: { reason: "empty", detail: "No projection definition was found." },
};
}
- let parsed;
+ let claimed = null;
+ if (isWkt(definition)) {
+ let parsed;
+ try {
+ parsed = wktParser(definition);
+ } catch (error) {
+ return {
+ error: {
+ reason: "unparsable",
+ detail: `The projection definition could not be parsed: ${error.message}`,
+ },
+ };
+ }
+ claimed = claimedCode(parsed);
+ }
+
+ if (claimed && (getProjection(claimed) || ensureProjection(claimed))) {
+ return { code: claimed };
+ }
+
+ const code = definitionCode(definition);
+ if (getProjection(code)) return { code };
+
try {
- parsed = wktParser(wkt);
+ proj4.defs(code, definition);
} catch (error) {
return {
error: {
@@ -235,18 +265,13 @@ export function registerProjectionFromWkt(wkt) {
};
}
- const claimed = claimedCode(parsed);
- if (claimed && (getProjection(claimed) || ensureProjection(claimed))) {
- return { code: claimed };
- }
-
- const code = wktCode(wkt);
- if (getProjection(code)) return { code };
-
- proj4.defs(code, wkt);
- if (!definitionUsable(code, parsed)) {
+ // Read the definition back rather than reusing the WKT parse: this is the only
+ // shape available for a proj4 string, and it is what proj4 will actually
+ // transform with either way.
+ const registered = proj4.defs(code);
+ if (!definitionUsable(code, registered)) {
delete proj4.defs[code];
- const method = parsed?.projName ?? "an unnamed projection method";
+ const method = registered?.projName ?? "an unnamed projection method";
return {
error: {
reason: "unsupported",
diff --git a/reactapp/components/map/shapefile/index.js b/reactapp/components/map/shapefile/index.js
index bb8a86c4..00c7e962 100644
--- a/reactapp/components/map/shapefile/index.js
+++ b/reactapp/components/map/shapefile/index.js
@@ -1,7 +1,7 @@
import { strFromU8 } from "fflate";
import {
ensureProjection,
- registerProjectionFromWkt,
+ registerProjectionDefinition,
} from "components/map/projections";
/**
@@ -86,6 +86,15 @@ function toArrayBuffer(bytes) {
);
}
+// A code is a short token like "EPSG:5070"; a definition is WKT or a proj4
+// string. Detected by shape rather than by trying one and falling back, so a
+// malformed definition is reported as such instead of as an unknown code.
+function looksLikeDefinition(value) {
+ return /^\s*(\+proj=|[A-Z_]*(PROJCS|GEOGCS|PROJCRS|GEOGCRS|GEODCRS)\s*\[)/i.test(
+ value,
+ );
+}
+
// Absence and failure are different inputs here, and keeping them apart is the
// point. A .prj that is genuinely missing falls back to what the author
// supplied; a .prj that failed to arrive was already reported upstream and never
@@ -96,7 +105,7 @@ function resolveProjection(prjBytes, fallbackProjection) {
// Decoded with fflate rather than TextDecoder: this runs in the browser and
// under the test runner, and one of those has no TextDecoder.
const wkt = strFromU8(prjBytes).trim();
- const registered = registerProjectionFromWkt(wkt);
+ const registered = registerProjectionDefinition(wkt);
if (registered.error) {
return {
error: {
@@ -110,6 +119,23 @@ function resolveProjection(prjBytes, fallbackProjection) {
}
if (fallbackProjection) {
+ // The field takes a definition as well as a code. A shapefile with no .prj
+ // in an uncommon CRS has no other way to be placed: there is no table entry
+ // to name, and the registration helper already accepts exactly this input.
+ if (looksLikeDefinition(fallbackProjection)) {
+ const registered = registerProjectionDefinition(fallbackProjection);
+ if (registered.error) {
+ return {
+ error: {
+ stage: "parse",
+ reason: "unresolvable_projection",
+ detail: registered.error.detail,
+ },
+ };
+ }
+ return { code: registered.code };
+ }
+
const resolved = ensureProjection(fallbackProjection);
if (!resolved) {
return {
diff --git a/reactapp/components/map/utilities.js b/reactapp/components/map/utilities.js
index 1ca7b86c..d5d124ae 100644
--- a/reactapp/components/map/utilities.js
+++ b/reactapp/components/map/utilities.js
@@ -122,6 +122,21 @@ export const sourcePropertiesOptions = {
required: {},
optional: {},
},
+ Shapefile: {
+ required: {
+ url: {
+ placeholder:
+ "URL of a zipped shapefile (.zip) or of its .shp component",
+ },
+ },
+ optional: {
+ // Used only when the source carries no .prj. Accepts a WKT or proj4
+ // definition as well as a code, because a .prj-less shapefile in an
+ // uncommon CRS has no other way to be placed.
+ projection: { placeholder: "EPSG:, or a WKT/proj4 definition" },
+ attributions: { placeholder: "Attributions" },
+ },
+ },
GeoTIFF: {
required: {
url: { placeholder: "Cloud Optimized GeoTIFF URL" },
diff --git a/reactapp/components/modals/MapLayer/MapLayer.js b/reactapp/components/modals/MapLayer/MapLayer.js
index 54664b42..63468d97 100644
--- a/reactapp/components/modals/MapLayer/MapLayer.js
+++ b/reactapp/components/modals/MapLayer/MapLayer.js
@@ -133,6 +133,12 @@ export function renameLayerInAttributeProps(attributeProps, oldName, newName) {
export const getLayerType = (sourceType) => {
if (sourceType === "GeoTIFF" || sourceType === "Zarr") return "WebGLTile";
+ // Explicit rather than left to the fallthrough below. "Shapefile" happens to
+ // match none of the substring tests, so it would reach VectorLayer anyway --
+ // but a label variant like "Zipped Shapefile Tile" would silently route to the
+ // wrong layer class with no error. The label is load-bearing, and it is also
+ // persisted user data: renaming one costs a migration over every dashboard.
+ if (sourceType === "Shapefile") return "VectorLayer";
if (sourceType.includes("Vector")) return "VectorTileLayer";
if (sourceType.includes("Raster")) return "WebGLTile";
if (sourceType.includes("Tile")) return "TileLayer";
From 6089eb4383a1a601da32542df243cf74ccb4b534 Mon Sep 17 00:00:00 2001
From: Corey Krewson
Date: Tue, 25 Aug 2026 10:38:33 -0700
Subject: [PATCH 06/18] feat(map): register Shapefile at every source-type
dispatch point
The sites were re-derived by searching for the existing type-name literals
rather than taken from a list, because that is the only way to find them: the
capability is encoded as strings scattered across modules, and the failure mode
of missing one is silent.
Five needed changing; a sixth comes free.
- The client-vector list gates both click queries and snapping. A type absent
from it does not error -- the snap path falls through to the feature-service
query and returns nothing.
- Attribute discovery gains a branch that reads field names from the .dbf.
- Style-field discovery routes through that same branch rather than getting a
second implementation. The two are otherwise independent trees with different
logic, and registering in only one gives working fields in one pane and an
empty list in the other -- so a test asserts they agree.
- The Style pane's supported-type list is a hard gate: absent from it, the tab
renders a dead-end panel and styling the layer is impossible no matter what
discovery returned. An existing test hardcoded that list in its expected
message and has been updated.
- The layer-property help text for clickTolerance and snapToFeatures enumerates
eligible types and is user-visible.
Snapping needs nothing further: it reads the shared client-vector list, and
features arriving through a loading strategy become snappable as they load.
The service-legend branch is deliberately untouched -- a shapefile layer takes
the style-derived legend path, as a rule-styled vector should.
Discovery reads the source, which acquisition caches against the resolved URL,
so the style pane and the attributes pane reading in turn cost one fetch
between them. When the source cannot be read, discovery returns an empty field
list rather than throwing, so a failure surfaces through the layer's error path
instead of breaking the editor.
Co-Authored-By: Claude Opus 5 (1M context)
---
.../components/map/shapefileDispatch.test.js | 224 ++++++++++++++++++
.../modals/MapLayer/StylePane.test.js | 43 +++-
reactapp/components/map/utilities.js | 61 ++++-
.../components/modals/MapLayer/StylePane.js | 10 +-
4 files changed, 332 insertions(+), 6 deletions(-)
create mode 100644 reactapp/__tests__/components/map/shapefileDispatch.test.js
diff --git a/reactapp/__tests__/components/map/shapefileDispatch.test.js b/reactapp/__tests__/components/map/shapefileDispatch.test.js
new file mode 100644
index 00000000..90dd898c
--- /dev/null
+++ b/reactapp/__tests__/components/map/shapefileDispatch.test.js
@@ -0,0 +1,224 @@
+import {
+ CLIENT_VECTOR_SOURCE_TYPES,
+ layerPropertiesOptions,
+ getLayerAttributes,
+ getStyleFields,
+ queryLayerFeatures,
+} from "components/map/utilities";
+import { acquireComponents } from "components/map/shapefile/acquire";
+import { interpretShapefile } from "components/map/shapefile/index";
+
+jest.mock("components/map/shapefile/acquire", () => ({
+ acquireComponents: jest.fn(),
+}));
+jest.mock("components/map/shapefile/index", () => ({
+ interpretShapefile: jest.fn(),
+}));
+
+const SOURCE_PROPS = {
+ type: "Shapefile",
+ props: { url: "https://example.org/basins.zip" },
+};
+
+const WITH_ATTRIBUTES = {
+ featureCollection: {
+ type: "FeatureCollection",
+ features: [
+ {
+ type: "Feature",
+ properties: { HUC8: "10190005", AREASQKM: 91, NAME: "Upper" },
+ geometry: { type: "Point", coordinates: [0, 0] },
+ },
+ // A second feature carrying one field the first lacks, so the union rather
+ // than the first record decides the field list.
+ {
+ type: "Feature",
+ properties: { HUC8: "10190006", STATES: "CO" },
+ geometry: { type: "Point", coordinates: [1, 1] },
+ },
+ ],
+ },
+ projectionCode: "EPSG:4326",
+};
+
+beforeEach(() => {
+ acquireComponents.mockReset();
+ interpretShapefile.mockReset();
+ acquireComponents.mockResolvedValue({
+ components: { shp: new Uint8Array() },
+ });
+ interpretShapefile.mockResolvedValue(WITH_ATTRIBUTES);
+});
+
+describe("client-vector source types", () => {
+ it("includes Shapefile, so clicks and snapping read from the map", () => {
+ // Absent from this list the snap path falls through to the feature-service
+ // query and returns nothing -- a silent failure, not an error.
+ expect(CLIENT_VECTOR_SOURCE_TYPES).toContain("Shapefile");
+ });
+});
+
+describe("queryLayerFeatures", () => {
+ it("reaches the client-vector branch for a shapefile layer instead of throwing", async () => {
+ // Without Shapefile in the client-vector list this throws "is not currently
+ // configured to be queried" -- it does not fall through to anything. The
+ // empty result here is the point: dispatch arrived, found no matching
+ // feature, and returned normally.
+ const mockMap = {
+ getView: jest.fn(() => ({
+ getResolution: jest.fn(),
+ getZoom: jest.fn(() => 10),
+ })),
+ forEachFeatureAtPixel: jest.fn((pixel, callback) => {
+ callback(null, {
+ get: jest.fn(() => "Some Other Layer"),
+ getProperties: () => ({ name: "Some Other Layer" }),
+ });
+ }),
+ };
+
+ const features = await queryLayerFeatures(
+ {
+ configuration: { props: { name: "Basins", source: SOURCE_PROPS } },
+ },
+ mockMap,
+ [0, 0],
+ [639, 366],
+ );
+
+ expect(features).toStrictEqual([]);
+ expect(mockMap.forEachFeatureAtPixel).toHaveBeenCalled();
+ });
+});
+
+describe("getLayerAttributes — Shapefile", () => {
+ it("returns the union of .dbf field names", async () => {
+ const attributes = await getLayerAttributes({
+ sourceProps: SOURCE_PROPS,
+ layerName: "Basins",
+ dashboard_uuid: "uuid",
+ });
+
+ expect(attributes.Basins.map((f) => f.name).sort()).toEqual([
+ "AREASQKM",
+ "HUC8",
+ "NAME",
+ "STATES",
+ ]);
+ // No alias source for a shapefile, so each field aliases to itself.
+ expect(attributes.Basins.every((f) => f.alias === f.name)).toBe(true);
+ });
+
+ it("passes the author's projection through as the fallback", async () => {
+ await getLayerAttributes({
+ sourceProps: {
+ ...SOURCE_PROPS,
+ props: { ...SOURCE_PROPS.props, projection: "EPSG:5070" },
+ },
+ layerName: "Basins",
+ });
+
+ expect(interpretShapefile).toHaveBeenCalledWith(expect.anything(), {
+ fallbackProjection: "EPSG:5070",
+ });
+ });
+
+ it("returns an empty list rather than throwing when the source cannot be read", async () => {
+ acquireComponents.mockResolvedValue({
+ error: { stage: "fetch", reason: "unreachable", detail: "no host" },
+ });
+
+ const attributes = await getLayerAttributes({
+ sourceProps: SOURCE_PROPS,
+ layerName: "Basins",
+ });
+
+ expect(attributes).toEqual({ Basins: [] });
+ });
+
+ it("returns an empty list when interpretation fails", async () => {
+ interpretShapefile.mockResolvedValue({
+ error: { stage: "parse", reason: "missing_projection", detail: "no prj" },
+ });
+
+ const attributes = await getLayerAttributes({
+ sourceProps: SOURCE_PROPS,
+ layerName: "Basins",
+ });
+
+ expect(attributes).toEqual({ Basins: [] });
+ });
+
+ it("returns an empty list for geometry with no attributes", async () => {
+ // Covers the no-.dbf case: the layer still renders and is styleable by
+ // geometry-independent rules, but offers no fields.
+ interpretShapefile.mockResolvedValue({
+ featureCollection: {
+ type: "FeatureCollection",
+ features: [
+ {
+ type: "Feature",
+ properties: {},
+ geometry: { type: "Point", coordinates: [0, 0] },
+ },
+ ],
+ },
+ projectionCode: "EPSG:4326",
+ });
+
+ const attributes = await getLayerAttributes({
+ sourceProps: SOURCE_PROPS,
+ layerName: "Basins",
+ });
+
+ expect(attributes.Basins).toEqual([]);
+ });
+});
+
+describe("getStyleFields — Shapefile", () => {
+ it("returns the same field list attribute discovery does", async () => {
+ // Registering in only one of the two discovery trees gives working fields in
+ // one pane and an empty list in the other, so this asserts they agree.
+ const styleFields = await getStyleFields({
+ sourceProps: SOURCE_PROPS,
+ layerProps: { name: "Basins" },
+ dashboard_uuid: "uuid",
+ });
+ const attributes = await getLayerAttributes({
+ sourceProps: SOURCE_PROPS,
+ layerName: "Basins",
+ dashboard_uuid: "uuid",
+ });
+
+ expect(styleFields.sort()).toEqual(
+ attributes.Basins.map((f) => f.name).sort(),
+ );
+ });
+
+ it("returns an empty list rather than throwing when the source cannot be read", async () => {
+ acquireComponents.mockResolvedValue({ cancelled: true });
+
+ const fields = await getStyleFields({
+ sourceProps: SOURCE_PROPS,
+ layerProps: { name: "Basins" },
+ });
+
+ expect(fields).toEqual([]);
+ });
+});
+
+describe("layerPropertiesOptions help text", () => {
+ it("names Shapefile among the types clickTolerance applies to", () => {
+ // This registry drives the editor's Layer Properties table, so the text is
+ // user-visible.
+ expect(layerPropertiesOptions.clickTolerance.placeholder).toContain(
+ "Shapefile",
+ );
+ });
+
+ it("names Shapefile among the types snapToFeatures applies to", () => {
+ expect(layerPropertiesOptions.snapToFeatures.placeholder).toContain(
+ "Shapefile",
+ );
+ });
+});
diff --git a/reactapp/__tests__/components/modals/MapLayer/StylePane.test.js b/reactapp/__tests__/components/modals/MapLayer/StylePane.test.js
index ffef912b..ef38c162 100644
--- a/reactapp/__tests__/components/modals/MapLayer/StylePane.test.js
+++ b/reactapp/__tests__/components/modals/MapLayer/StylePane.test.js
@@ -317,7 +317,12 @@ test("StylePane Updating Existing GeoJSON", async () => {
test("StylePane Styling not available", async () => {
render();
- const supportedTypes = ["GeoJSON", "ESRI Feature Service", "PMTiles Vector"];
+ const supportedTypes = [
+ "GeoJSON",
+ "ESRI Feature Service",
+ "PMTiles Vector",
+ "Shapefile",
+ ];
expect(
await screen.findByText(
`Custom Styling is only available for ${supportedTypes.join(", ")} layers.`,
@@ -325,6 +330,42 @@ test("StylePane Styling not available", async () => {
).toBeInTheDocument();
});
+test("StylePane offers the style editor for a Shapefile source", async () => {
+ // The gate this exercises is separate from field discovery: absent from the
+ // supported list, the tab renders a dead-end panel and styling the layer is
+ // impossible no matter what fields were found. Asserted on the absence of that
+ // panel, which is decided at render rather than after discovery resolves.
+ //
+ // Discovery is stubbed so the effect does not reach the network for a URL that
+ // does not exist; what it returns is covered by its own suite.
+ const styleFieldsSpy = jest
+ .spyOn(utilities, "getStyleFields")
+ .mockResolvedValue(["HUC8", "AREASQKM"]);
+
+ render(
+ ,
+ );
+
+ expect(
+ screen.queryByText(/Custom Styling is only available for/),
+ ).not.toBeInTheDocument();
+ // And discovery is reached rather than skipped, so the rule editor has fields
+ // to offer once it resolves.
+ await waitFor(() => {
+ expect(styleFieldsSpy).toHaveBeenCalledWith(
+ expect.objectContaining({
+ sourceProps: expect.objectContaining({ type: "Shapefile" }),
+ }),
+ );
+ });
+ styleFieldsSpy.mockRestore();
+});
+
test("StylePane switches to rules mode and syncs rules/defaultStyle from JSON", async () => {
render();
// Switch to rules mode
diff --git a/reactapp/components/map/utilities.js b/reactapp/components/map/utilities.js
index d5d124ae..0564e3c2 100644
--- a/reactapp/components/map/utilities.js
+++ b/reactapp/components/map/utilities.js
@@ -1,4 +1,6 @@
import PropTypes from "prop-types";
+import { acquireComponents } from "components/map/shapefile/acquire";
+import { interpretShapefile } from "components/map/shapefile/index";
import { convertXML } from "simple-xml-to-json";
import { transform } from "ol/proj";
import Feature from "ol/Feature";
@@ -18,7 +20,15 @@ import Protobuf from "pbf";
// Source types whose features live in a client-side OL VectorSource (vs
// server-rendered services queried remotely).
-export const CLIENT_VECTOR_SOURCE_TYPES = ["GeoJSON", "ESRI Feature Service"];
+// Source types whose features live in a client-side vector source, so a click or
+// a snap can read them straight off the map rather than querying a service.
+// A type missing from here does not error -- the snap path falls through to the
+// feature-service query, which returns nothing -- so the failure is silent.
+export const CLIENT_VECTOR_SOURCE_TYPES = [
+ "GeoJSON",
+ "ESRI Feature Service",
+ "Shapefile",
+];
// Coerce an optional numeric layer prop: GUI inputs emit strings, so accept
// any numeric value but treat null/undefined/blank/non-numeric as unset.
@@ -264,12 +274,12 @@ export const layerPropertiesOptions = {
clickTolerance: {
type: "number",
placeholder:
- "Pixel tolerance for ESRI Image and Map Service identify (click) requests (default 10) and for GeoJSON / ESRI Feature Service feature queries (default 0). Also sets the snap radius when Snap To Features is on (default 15).",
+ "Pixel tolerance for ESRI Image and Map Service identify (click) requests (default 10) and for GeoJSON / ESRI Feature Service / Shapefile feature queries (default 0). Also sets the snap radius when Snap To Features is on (default 15).",
},
snapToFeatures: {
type: "checkbox",
placeholder:
- "Snap hover/click to the nearest feature of this layer (ESRI Map Service, GeoJSON, or ESRI Feature Service).",
+ "Snap hover/click to the nearest feature of this layer (ESRI Map Service, GeoJSON, ESRI Feature Service, or Shapefile).",
},
snapSublayer: {
type: "number",
@@ -1012,7 +1022,15 @@ export async function getStyleFields({
isDynamicMapLayer = false,
}) {
let fields = [];
- if (isDynamicMapLayer || sourceProps.type === "PMTiles Vector") {
+ // Shapefile joins the delegating branch rather than getting a second
+ // implementation. Attribute discovery and style-field discovery are otherwise
+ // independent trees, and registering in only one gives working fields in one
+ // pane and an empty list in the other.
+ if (
+ isDynamicMapLayer ||
+ sourceProps.type === "PMTiles Vector" ||
+ sourceProps.type === "Shapefile"
+ ) {
const attributes = await getLayerAttributes({
sourceProps,
layerName: layerProps?.name ?? "",
@@ -1125,6 +1143,12 @@ export async function getLayerAttributes({
attributes = await getKMLLayerAttributes(sourceUrl, layerName);
} else if (sourceType === "PMTiles Vector") {
attributes = await getPMTilesVectorLayerAttributes(sourceUrl);
+ } else if (sourceType === "Shapefile") {
+ attributes = await getShapefileLayerAttributes(
+ sourceUrl,
+ sourceProps?.props?.projection,
+ layerName,
+ );
} else {
throw Error(`${sourceType} is not currently configured to be queried`);
}
@@ -1132,6 +1156,35 @@ export async function getLayerAttributes({
return attributes;
}
+// Field names come from the .dbf, which means reading the source. Acquisition is
+// cached against the resolved URL, so the style pane and the attributes pane
+// reading in turn cost one fetch between them.
+async function getShapefileLayerAttributes(
+ sourceUrl,
+ fallbackProjection,
+ layerName,
+) {
+ const acquired = await acquireComponents(sourceUrl);
+ if (acquired.error || acquired.cancelled) return { [layerName]: [] };
+
+ const interpreted = await interpretShapefile(acquired.components, {
+ fallbackProjection,
+ });
+ if (interpreted.error) return { [layerName]: [] };
+
+ const fieldNames = new Set(
+ (interpreted.featureCollection.features ?? []).flatMap((feature) =>
+ Object.keys(feature.properties ?? {}),
+ ),
+ );
+ return {
+ [layerName]: Array.from(fieldNames).map((field) => ({
+ name: field,
+ alias: field,
+ })),
+ };
+}
+
async function getPMTilesVectorLayerAttributes(sourceUrl) {
// Default to tile 0/0/0 if not specified, or allow passing tile coordinates as needed
const z = 0,
diff --git a/reactapp/components/modals/MapLayer/StylePane.js b/reactapp/components/modals/MapLayer/StylePane.js
index 3f38f3df..bea34222 100644
--- a/reactapp/components/modals/MapLayer/StylePane.js
+++ b/reactapp/components/modals/MapLayer/StylePane.js
@@ -428,7 +428,15 @@ const StylePane = ({
);
}
- const supportedTypes = ["GeoJSON", "ESRI Feature Service", "PMTiles Vector"];
+ // Absent from this list, the Style tab renders a dead-end "not available for
+ // this source type" panel instead of the rule editor -- so styling a shapefile
+ // layer would be impossible regardless of what field discovery returned.
+ const supportedTypes = [
+ "GeoJSON",
+ "ESRI Feature Service",
+ "PMTiles Vector",
+ "Shapefile",
+ ];
const isDynamicMapLayer = findSelectOptionByValue(
dynamicMapLayers,
sourceProps.type,
From 61165278b4f51c1ddad699271562a8768e6e0bea Mon Sep 17 00:00:00 2001
From: Corey Krewson
Date: Tue, 25 Aug 2026 10:52:59 -0700
Subject: [PATCH 07/18] feat(map): preserve shapefile layers across unrelated
layer changes
The reconciliation sweep rebuilds every vector layer on any change to the layer
array, so without this an opacity edit on an unrelated layer -- or one frame of
a raster time-slider -- costs a full refetch, decompress and reparse of the
whole archive. Preservation keys on the layer's name plus its resolved source
URL.
The keep predicate gains a branch rather than being generalized. The
plugin-provenance check every existing plugin layer depends on is left exactly
as it was, so preservation for those is untouched by this change.
Preservation has a cost the add path was hiding: style is applied only when a
layer is constructed, and the cosmetic prop sync carries only the props
OpenLayers has first-class setters for. A preserved layer would therefore
ignore a style-rule edit entirely -- which would contradict styling working on
a shapefile layer at all. The style application is now factored out of the add
path and re-applied to preserved layers when it differs from what was last
applied.
Two duplicate-layer hazards are closed. A run that has been superseded no
longer adds its layer: it sits in no newer run's removal snapshot, so it would
never be collected, leaving features drawn twice and every clicked feature
reported twice in the popup. And the removal sweep now runs whenever the map
actually holds layers rather than only when reconciliation state was recorded,
because a run starting while a previous one is still loading sees no recorded
state -- and gating removal on it let both runs' layers sit on the map. With no
recorded state nothing is kept, so that case rebuilds rather than duplicates.
In-flight loads are aborted when the layer is removed, when a run is
superseded, and on unmount, so a fetch and decompression do not keep running
for a layer nobody will see.
The tests drive the loader directly, because OpenLayers pulls it only when a
layer renders and a jsdom map has no size. That makes the assertion the right
one anyway: a preserved layer keeps its source and its loaded-extent
bookkeeping, so driving it again is a no-op, while a rebuilt layer loads from
scratch. They wait on observable post-conditions rather than fixed delays.
Co-Authored-By: Claude Opus 5 (1M context)
---
.../map/shapefilePreservation.test.js | 245 ++++++++++++++++++
reactapp/components/map/Map.js | 136 ++++++++--
2 files changed, 353 insertions(+), 28 deletions(-)
create mode 100644 reactapp/__tests__/components/map/shapefilePreservation.test.js
diff --git a/reactapp/__tests__/components/map/shapefilePreservation.test.js b/reactapp/__tests__/components/map/shapefilePreservation.test.js
new file mode 100644
index 00000000..44c21cff
--- /dev/null
+++ b/reactapp/__tests__/components/map/shapefilePreservation.test.js
@@ -0,0 +1,245 @@
+import { useRef, useState } from "react";
+import { render, screen, waitFor } from "@testing-library/react";
+import PropTypes from "prop-types";
+import { get as getProjection } from "ol/proj.js";
+import MapComponent from "components/map/Map";
+import MapContextProvider, {
+ useMapContext,
+} from "components/contexts/MapContext";
+import { VariableInputsContext } from "components/contexts/Contexts";
+import { acquireComponents } from "components/map/shapefile/acquire";
+import { interpretShapefile } from "components/map/shapefile/index";
+
+global.ResizeObserver = require("resize-observer-polyfill");
+
+// Acquisition stands in for "did this layer load again". The pipeline itself is
+// covered by its own suites.
+jest.mock("components/map/shapefile/acquire", () => ({
+ acquireComponents: jest.fn(),
+}));
+jest.mock("components/map/shapefile/index", () => ({
+ interpretShapefile: jest.fn(),
+}));
+
+const FULL_EXTENT = [-Infinity, -Infinity, Infinity, Infinity];
+
+const COLLECTION = {
+ type: "FeatureCollection",
+ crs: { type: "name", properties: { name: "EPSG:4326" } },
+ features: [
+ {
+ type: "Feature",
+ properties: { HUC8: "10190005" },
+ geometry: { type: "Point", coordinates: [-105, 40] },
+ },
+ ],
+};
+
+function shapefileLayer({
+ url = "https://example.org/basins.zip",
+ style,
+} = {}) {
+ return {
+ type: "VectorLayer",
+ props: {
+ name: "Basins",
+ source: { type: "Shapefile", props: { url } },
+ },
+ ...(style === undefined ? {} : { style }),
+ };
+}
+
+function otherLayer({ opacity = 1 } = {}) {
+ return {
+ type: "TileLayer",
+ props: {
+ name: "Basemap",
+ opacity,
+ source: { type: "Image Tile", props: { url: "https://example.org/{z}" } },
+ },
+ };
+}
+
+let mapRef;
+let setLayers;
+
+const Harness = ({ initialLayers }) => {
+ const visualizationRef = useRef();
+ const [layers, setLayersState] = useState(initialLayers);
+ const { mapReady } = useMapContext();
+ mapRef = visualizationRef;
+ setLayers = setLayersState;
+ return (
+
+
+
{mapReady ? "Map Ready" : "Map Not Ready"}
+
+ );
+};
+Harness.propTypes = { initialLayers: PropTypes.array };
+
+async function mount(initialLayers) {
+ render(
+
+
+
+
+ ,
+ );
+ expect(await screen.findByText("Map Ready")).toBeInTheDocument();
+ await waitFor(() => expect(shapefileLayers()).toHaveLength(1));
+}
+
+function shapefileLayers() {
+ return (mapRef?.current?.getLayers?.().getArray() ?? []).filter(
+ (layer) => !!layer.getSource?.()?.get?.("shapefileController"),
+ );
+}
+
+function layerNamed(name) {
+ return (mapRef?.current?.getLayers?.().getArray() ?? []).find(
+ (layer) => layer.get("name") === name,
+ );
+}
+
+// The loader is pulled by OpenLayers only when a layer renders, and a jsdom map
+// has no size -- so drive it directly. This also makes the assertion the right
+// one: a preserved layer keeps its source and its loaded-extent bookkeeping, so
+// driving it again is a no-op, while a rebuilt layer has a fresh source that
+// loads from scratch.
+async function drive() {
+ shapefileLayers().forEach((layer) => {
+ layer.getSource().loadFeatures(FULL_EXTENT, 1, getProjection("EPSG:3857"));
+ });
+ await new Promise((resolve) => setTimeout(resolve, 0));
+}
+
+// Wait on an observable post-condition rather than a fixed delay, so a slow
+// machine cannot turn these into flakes. Reconciliation is asynchronous, so
+// something it did has to be visible before the assertions run.
+async function reconciled(condition) {
+ await waitFor(condition);
+}
+
+beforeEach(() => {
+ mapRef = undefined;
+ setLayers = undefined;
+ acquireComponents.mockReset();
+ interpretShapefile.mockReset();
+ acquireComponents.mockResolvedValue({
+ components: { shp: new Uint8Array() },
+ });
+ interpretShapefile.mockResolvedValue({
+ featureCollection: COLLECTION,
+ projectionCode: "EPSG:4326",
+ });
+});
+
+describe("shapefile layer preservation", () => {
+ it("does not load again when an unrelated layer's opacity changes", async () => {
+ // The reconciliation sweep rebuilds every vector layer on any change to the
+ // layer array, so without preservation an opacity edit elsewhere -- or one
+ // frame of a raster time-slider -- costs a full refetch and reparse.
+ await mount([shapefileLayer(), otherLayer({ opacity: 1 })]);
+ await drive();
+ expect(acquireComponents).toHaveBeenCalledTimes(1);
+ const original = layerNamed("Basins");
+
+ setLayers([shapefileLayer(), otherLayer({ opacity: 0.4 })]);
+ await reconciled(() =>
+ expect(layerNamed("Basemap").getOpacity()).toBeCloseTo(0.4),
+ );
+ await drive();
+
+ expect(acquireComponents).toHaveBeenCalledTimes(1);
+ // Same instance, so its features and loaded-extent bookkeeping survived.
+ expect(layerNamed("Basins")).toBe(original);
+ });
+
+ it("loads again when the resolved url changes", async () => {
+ await mount([shapefileLayer()]);
+ await drive();
+ expect(acquireComponents).toHaveBeenCalledTimes(1);
+
+ const original = shapefileLayers()[0];
+ setLayers([shapefileLayer({ url: "https://example.org/gages.zip" })]);
+ await reconciled(() => expect(shapefileLayers()[0]).not.toBe(original));
+ await drive();
+
+ expect(acquireComponents.mock.calls.length).toBeGreaterThan(1);
+ expect(acquireComponents).toHaveBeenLastCalledWith(
+ "https://example.org/gages.zip",
+ expect.anything(),
+ );
+ });
+
+ it("does not load again when a re-render resolves to the same url", async () => {
+ await mount([shapefileLayer()]);
+ await drive();
+ expect(acquireComponents).toHaveBeenCalledTimes(1);
+
+ // A new config object carrying an identical resolved url. Nothing observable
+ // changes when a layer is preserved, so the instance check is the assertion
+ // and the load count corroborates it.
+ const original = shapefileLayers()[0];
+ setLayers([shapefileLayer()]);
+ await reconciled(() => expect(shapefileLayers()).toHaveLength(1));
+ await drive();
+
+ expect(shapefileLayers()[0]).toBe(original);
+ expect(acquireComponents).toHaveBeenCalledTimes(1);
+ });
+
+ it("repaints a style edit on a preserved layer without loading again", async () => {
+ // Style is otherwise applied only when a layer is constructed, so a
+ // preserved layer would silently ignore a style edit -- which would
+ // contradict styling working on a shapefile layer at all.
+ await mount([shapefileLayer({ style: { a: 1 } })]);
+ await drive();
+ const original = layerNamed("Basins");
+ expect(acquireComponents).toHaveBeenCalledTimes(1);
+
+ setLayers([shapefileLayer({ style: { a: 2 } })]);
+ await waitFor(() => {
+ expect(layerNamed("Basins").get("appliedStyle")).toEqual({ a: 2 });
+ });
+ await drive();
+
+ expect(acquireComponents).toHaveBeenCalledTimes(1);
+ expect(layerNamed("Basins")).toBe(original);
+ });
+
+ it("keeps exactly one instance of the layer across a change", async () => {
+ await mount([shapefileLayer(), otherLayer()]);
+
+ setLayers([shapefileLayer(), otherLayer({ opacity: 0.5 })]);
+ await reconciled(() =>
+ expect(layerNamed("Basemap").getOpacity()).toBeCloseTo(0.5),
+ );
+
+ expect(shapefileLayers()).toHaveLength(1);
+ });
+});
+
+describe("shapefile load cancellation", () => {
+ it("aborts an in-flight load when the layer is removed", async () => {
+ let capturedSignal;
+ acquireComponents.mockImplementation((url, options) => {
+ capturedSignal = options?.signal;
+ return new Promise(() => {});
+ });
+
+ await mount([shapefileLayer()]);
+ await drive();
+ expect(capturedSignal).toBeDefined();
+ expect(capturedSignal.aborted).toBe(false);
+
+ setLayers([otherLayer()]);
+
+ await waitFor(() => {
+ expect(capturedSignal.aborted).toBe(true);
+ });
+ });
+});
diff --git a/reactapp/components/map/Map.js b/reactapp/components/map/Map.js
index 69e127fc..4234c6ca 100644
--- a/reactapp/components/map/Map.js
+++ b/reactapp/components/map/Map.js
@@ -10,6 +10,7 @@ import moduleLoader, {
// matters, because layers are constructed concurrently and a registration that
// waited on anything async would race them.
import { isNativelyResolvable } from "components/map/projections";
+import { CANCEL_REASON } from "components/map/layerStatus";
import LayersControl from "components/map/LayersControl";
import FloatingMapControl from "components/map/FloatingMapControl";
import LegendControl from "components/map/LegendControl";
@@ -59,6 +60,45 @@ const InfoDiv = styled.div`
z-index: 1000;
`;
+// Apply a layer config's style to an OL layer.
+//
+// Extracted from the add path so a *preserved* layer can be restyled too.
+// Preservation keeps the layer instance, and the cosmetic prop sync handles only
+// the props OL has first-class setters for -- so without this, editing a
+// preserved layer's style rules would change nothing on the map.
+async function applyLayerStyle(olLayer, layerConfig) {
+ if (!layerConfig.style) return;
+
+ const isWebGLTileRampStyle =
+ layerConfig.type === "WebGLTile" &&
+ layerConfig.style &&
+ typeof layerConfig.style === "object" &&
+ !Array.isArray(layerConfig.style) &&
+ "color" in layerConfig.style;
+
+ if (isWebGLTileRampStyle) {
+ olLayer.setStyle(layerConfig.style);
+ return;
+ }
+
+ try {
+ await applyStyle(olLayer, layerConfig.style);
+ } catch (err) {
+ if (err.message !== "Cannot read properties of undefined (reading 'crs')") {
+ const styleFunction = createJsonStyleFunction(layerConfig.style);
+ if (typeof olLayer.setStyle === "function") {
+ olLayer.setStyle(styleFunction);
+ }
+ }
+ }
+}
+
+// Stop an in-flight shapefile load. Called when the layer is going away, so the
+// fetch and decompression do not keep running for a layer nobody will see.
+function abortShapefileLoad(olLayer, reason) {
+ olLayer?.getSource?.()?.get?.("shapefileController")?.abort?.(reason);
+}
+
const MapComponent = ({
mapConfig,
mapExtent,
@@ -164,6 +204,10 @@ const MapComponent = ({
// istanbul ignore next
if (visualizationRef.current) {
if (activeFadeRef.current) activeFadeRef.current();
+ visualizationRef.current
+ .getLayers()
+ .getArray()
+ .forEach((layer) => abortShapefileLoad(layer, CANCEL_REASON.UNMOUNT));
visualizationRef.current.setTarget(undefined);
visualizationRef.current = null;
}
@@ -251,6 +295,11 @@ const MapComponent = ({
// decision. Collect those here and apply after the loop so the in-place
// update doesn't interfere with layersToKeep membership checks.
const runtimeLayerUpdates = [];
+ // Preserved shapefile layers, collected the same way. Identity is the
+ // layer's name plus its resolved source URL: rebuilding refetches and
+ // reparses the whole archive, which an unrelated edit -- an opacity change
+ // on another layer, one frame of a raster time-slider -- should not cost.
+ const shapefileLayerUpdates = [];
if (currentLayers.current.length) {
const newLayerProps = (layers ?? []).map((l) => l.props);
@@ -310,6 +359,31 @@ const MapComponent = ({
// layerId) fall through and let the layer be torn down + rebuilt.
}
+ // Additive branch: the plugin-provenance check above is left exactly
+ // as it was rather than generalized, so preservation for plugin layers
+ // is untouched by this.
+ if (
+ currentLayer?.props?.source?.type === "Shapefile" &&
+ currentLayer.type === "VectorLayer"
+ ) {
+ const incoming = (layers ?? []).find(
+ (candidate) =>
+ candidate?.props?.source?.type === "Shapefile" &&
+ candidate?.props?.name === currentLayer.props.name &&
+ candidate?.props?.source?.props?.url ===
+ currentLayer.props.source?.props?.url,
+ );
+ if (incoming) {
+ layersToKeep.push(incoming.props.name);
+ shapefileLayerUpdates.push({
+ name: incoming.props.name,
+ newProps: incoming.props,
+ config: incoming,
+ });
+ return;
+ }
+ }
+
const shouldKeep =
newLayerProps.some((newProps) =>
valuesEqual(newProps, currentLayer.props),
@@ -318,7 +392,15 @@ const MapComponent = ({
layersToKeep.push(currentLayer.props.name);
}
});
+ }
+ // The removal sweep runs whenever the map actually holds layers, not only
+ // when reconciliation state was recorded. A run that starts while a
+ // previous one is still loading sees no recorded state, and gating removal
+ // on it would let both runs' layers sit on the map -- features drawn twice,
+ // and every clicked feature reported twice in the popup. With no recorded
+ // state nothing is kept, so this rebuilds rather than duplicates.
+ if (currentMapLayers.length) {
const keptRuntimeLayerIds = new Set(
runtimeLayerUpdates.map((u) => u.layerId),
);
@@ -329,6 +411,8 @@ const MapComponent = ({
return;
}
if (!layersToKeep.includes(layerName)) {
+ // Stop any load still running for a layer that is going away.
+ abortShapefileLoad(layer, CANCEL_REASON.REMOVED);
layersToRemove.push(layer);
}
});
@@ -342,6 +426,20 @@ const MapComponent = ({
updateOlLayerProps(olLayer, newProps);
}
});
+
+ // Same for preserved shapefile layers -- plus the style, which the
+ // cosmetic sync does not carry. Without this a style-rule edit on a
+ // preserved layer would change nothing, since the style is otherwise
+ // only applied when a layer is constructed.
+ shapefileLayerUpdates.forEach(({ name, newProps, config }) => {
+ const olLayer = currentMapLayers.find((l) => l.get("name") === name);
+ if (!olLayer) return;
+ updateOlLayerProps(olLayer, newProps);
+ if (!valuesEqual(olLayer.get("appliedStyle"), config.style)) {
+ olLayer.set("appliedStyle", config.style);
+ applyLayerStyle(olLayer, config);
+ }
+ });
}
// setup constants for handling new layers
@@ -438,6 +536,15 @@ const MapComponent = ({
}
}
+ // A run that has already been superseded must not add its layer:
+ // it is in no newer run's removal snapshot, so it would never be
+ // collected -- leaving features drawn twice and every clicked
+ // feature reported twice in the popup.
+ if (myToken !== layerSyncToken.current) {
+ abortShapefileLoad(newLayer, CANCEL_REASON.SUPERSEDED);
+ return;
+ }
+ newLayer.set("appliedStyle", layerConfig.style);
map.addLayer(newLayer);
if (
@@ -584,34 +691,7 @@ const MapComponent = ({
}
}
- if (layerConfig.style) {
- const isWebGLTileRampStyle =
- layerConfig.type === "WebGLTile" &&
- layerConfig.style &&
- typeof layerConfig.style === "object" &&
- !Array.isArray(layerConfig.style) &&
- "color" in layerConfig.style;
-
- if (isWebGLTileRampStyle) {
- newLayer.setStyle(layerConfig.style);
- } else {
- try {
- await applyStyle(newLayer, layerConfig.style);
- } catch (err) {
- if (
- err.message !==
- "Cannot read properties of undefined (reading 'crs')"
- ) {
- const styleFunction = createJsonStyleFunction(
- layerConfig.style,
- );
- if (typeof newLayer.setStyle === "function") {
- newLayer.setStyle(styleFunction);
- }
- }
- }
- }
- }
+ await applyLayerStyle(newLayer, layerConfig);
} catch (err) {
if (
err &&
From 0d08b69213843abdf8917279ac284725631b9b00 Mon Sep 17 00:00:00 2001
From: Corey Krewson
Date: Tue, 25 Aug 2026 11:22:09 -0700
Subject: [PATCH 08/18] feat(map): surface shapefile load state and failures
Failures and an in-flight indication go to the existing map-level alert, which
is not gated on the author's layers-control toggle. That control is opt-in per
dashboard and collapsed to an icon by default, so routing status only there
would leave a viewer with nothing at all on any dashboard whose author disabled
it -- and a failure rendering as a blank layer is the one outcome this must
avoid. The control still carries the richer per-layer detail when enabled.
Every failure class reaches the user with its own message: the observed and
permitted size when a source is refused, the coordinate system when one cannot
be resolved, the component and its status when one fails, and for a fetch-stage
failure the candidate causes named together, since a browser cannot tell them
apart.
Retry is offered only where re-running the same request could succeed. A
missing projection, an unresolvable coordinate system, a malformed component
and a source over the size ceiling all need the author to change something, so
a button for them would invite a viewer to re-download megabytes and fail
identically. The plugin path already gates its own retry this way.
Status is read from the source's controller rather than from a request id --
there is no backend request behind a client-parsed source, so the existing
progress channel has nothing to report for one. It is mirrored into component
state only so it can be rendered, and pruned to the layers actually on the map
after each reconciliation: a rebuilt layer must not inherit the previous
instance's failure, and a stale error must not suppress the replacement's
loading indication.
Retry goes through the source's refresh, which is the only primitive that
causes the loader to run again, and a test drives the loader afterward to prove
it did rather than asserting on internal state that would pass either way.
R19 and R21 -- the author-facing remedy text and the elapsed-time escalation --
move to the editor unit, where the load action they attach to is built.
Co-Authored-By: Claude Opus 5 (1M context)
---
.../components/map/shapefileStatus.test.js | 312 ++++++++++++++++++
reactapp/components/map/LayersControl.js | 62 +++-
reactapp/components/map/Map.js | 98 +++++-
3 files changed, 470 insertions(+), 2 deletions(-)
create mode 100644 reactapp/__tests__/components/map/shapefileStatus.test.js
diff --git a/reactapp/__tests__/components/map/shapefileStatus.test.js b/reactapp/__tests__/components/map/shapefileStatus.test.js
new file mode 100644
index 00000000..7c7151ac
--- /dev/null
+++ b/reactapp/__tests__/components/map/shapefileStatus.test.js
@@ -0,0 +1,312 @@
+import { useRef, useState } from "react";
+import { render, screen, fireEvent, waitFor } from "@testing-library/react";
+import PropTypes from "prop-types";
+import { get as getProjection } from "ol/proj.js";
+import MapComponent from "components/map/Map";
+import LayersControl from "components/map/LayersControl";
+import MapContextProvider, {
+ useMapContext,
+} from "components/contexts/MapContext";
+import { VariableInputsContext } from "components/contexts/Contexts";
+import { ERROR_KIND } from "components/map/layerStatus";
+import { acquireComponents } from "components/map/shapefile/acquire";
+import { interpretShapefile } from "components/map/shapefile/index";
+
+global.ResizeObserver = require("resize-observer-polyfill");
+
+jest.mock("components/map/shapefile/acquire", () => ({
+ acquireComponents: jest.fn(),
+}));
+jest.mock("components/map/shapefile/index", () => ({
+ interpretShapefile: jest.fn(),
+}));
+
+const FULL_EXTENT = [-Infinity, -Infinity, Infinity, Infinity];
+
+const COLLECTION = {
+ type: "FeatureCollection",
+ crs: { type: "name", properties: { name: "EPSG:4326" } },
+ features: [
+ {
+ type: "Feature",
+ properties: {},
+ geometry: { type: "Point", coordinates: [-105, 40] },
+ },
+ ],
+};
+
+const SHAPEFILE_LAYER = {
+ type: "VectorLayer",
+ props: {
+ name: "Basins",
+ source: {
+ type: "Shapefile",
+ props: { url: "https://example.org/basins.zip" },
+ },
+ },
+};
+
+let mapRef;
+
+let setLayers;
+
+const Harness = ({ layers: initialLayers, layerControl }) => {
+ const visualizationRef = useRef();
+ const [layers, setLayersState] = useState(initialLayers);
+ const { mapReady } = useMapContext();
+ mapRef = visualizationRef;
+ setLayers = setLayersState;
+ return (
+
+
+
{mapReady ? "Map Ready" : "Map Not Ready"}
+
+ );
+};
+Harness.propTypes = { layers: PropTypes.array, layerControl: PropTypes.bool };
+
+async function mount({ layerControl = false } = {}) {
+ render(
+
+
+
+
+ ,
+ );
+ expect(await screen.findByText("Map Ready")).toBeInTheDocument();
+ await waitFor(() => expect(shapefileSource()).toBeDefined());
+}
+
+function shapefileSource() {
+ return (mapRef?.current?.getLayers?.().getArray() ?? [])
+ .map((layer) => layer.getSource?.())
+ .find((source) => !!source?.get?.("shapefileController"));
+}
+
+// The loader is pulled by OpenLayers only when a layer renders, and a jsdom map
+// has no size.
+function drive() {
+ shapefileSource().loadFeatures(FULL_EXTENT, 1, getProjection("EPSG:3857"));
+}
+
+beforeEach(() => {
+ mapRef = undefined;
+ acquireComponents.mockReset();
+ interpretShapefile.mockReset();
+ acquireComponents.mockResolvedValue({
+ components: { shp: new Uint8Array() },
+ });
+ interpretShapefile.mockResolvedValue({
+ featureCollection: COLLECTION,
+ projectionCode: "EPSG:4326",
+ });
+});
+
+describe("map-level surfacing", () => {
+ it("reports a failure even with the layers control disabled", async () => {
+ // The layers control is opt-in per dashboard and collapsed by default, so a
+ // dashboard with it off must still not render a failure as a blank layer.
+ acquireComponents.mockResolvedValue({
+ error: {
+ stage: "fetch",
+ reason: "unreachable",
+ detail: "The shapefile could not be fetched.",
+ },
+ });
+
+ await mount({ layerControl: false });
+ drive();
+
+ const alert = await screen.findByRole("alert");
+ expect(alert).toHaveTextContent("Basins");
+ expect(alert).toHaveTextContent("could not be fetched");
+ });
+
+ it("reports a load in flight even with the layers control disabled", async () => {
+ acquireComponents.mockImplementation(() => new Promise(() => {}));
+
+ await mount({ layerControl: false });
+ drive();
+
+ const status = await screen.findByRole("status");
+ expect(status).toHaveTextContent("Loading Basins");
+ });
+
+ it("clears the in-flight indication once the load succeeds", async () => {
+ await mount({ layerControl: false });
+ drive();
+
+ await waitFor(() => {
+ expect(screen.queryByRole("status")).not.toBeInTheDocument();
+ });
+ expect(screen.queryByRole("alert")).not.toBeInTheDocument();
+ });
+
+ it("names the size ceiling and the observed size when a source is refused", async () => {
+ acquireComponents.mockResolvedValue({
+ error: {
+ stage: "fetch",
+ reason: "too_large",
+ observed: 90 * 1024 * 1024,
+ permitted: 25 * 1024 * 1024,
+ detail:
+ "The shapefile expands to at least 90.0 MB, above the 25.0 MB permitted.",
+ },
+ });
+
+ await mount({ layerControl: false });
+ drive();
+
+ const alert = await screen.findByRole("alert");
+ expect(alert).toHaveTextContent("90.0 MB");
+ expect(alert).toHaveTextContent("25.0 MB");
+ });
+
+ it("names the unresolvable coordinate system", async () => {
+ interpretShapefile.mockResolvedValue({
+ error: {
+ stage: "parse",
+ reason: "unresolvable_projection",
+ detail: 'The projection "Totally_Not_Real" could not be resolved.',
+ },
+ });
+
+ await mount({ layerControl: false });
+ drive();
+
+ expect(await screen.findByRole("alert")).toHaveTextContent(
+ "Totally_Not_Real",
+ );
+ });
+});
+
+describe("retry wiring and teardown", () => {
+ it("retry from the layers control re-invokes the loader", async () => {
+ acquireComponents.mockResolvedValue({
+ error: { stage: "fetch", reason: "unreachable", detail: "no host" },
+ });
+
+ await mount({ layerControl: true });
+ drive();
+ await screen.findAllByRole("alert");
+ expect(acquireComponents).toHaveBeenCalledTimes(1);
+
+ fireEvent.click(await screen.findByLabelText("Show Layers Control"));
+ fireEvent.click(await screen.findByLabelText("Retry Basins"));
+
+ // Reset goes through the source's refresh, which is the only primitive that
+ // causes the loader to run again -- so driving it once more loads afresh.
+ drive();
+ await waitFor(() => {
+ expect(acquireComponents.mock.calls.length).toBeGreaterThan(1);
+ });
+ });
+
+ it("discards a layer's status when the layer is removed", async () => {
+ acquireComponents.mockResolvedValue({
+ error: { stage: "fetch", reason: "unreachable", detail: "no host" },
+ });
+
+ await mount({ layerControl: false });
+ drive();
+ expect(await screen.findByRole("alert")).toBeInTheDocument();
+
+ setLayers([]);
+
+ // Status lives keyed on layer name, so a removed layer's failure must not
+ // linger -- and must not suppress a replacement's loading indication.
+ await waitFor(() => {
+ expect(screen.queryByRole("alert")).not.toBeInTheDocument();
+ });
+ });
+});
+
+describe("per-layer rows in the layers control", () => {
+ function renderControl(shapefileStatus, onRetryShapefile) {
+ const layer = {
+ get: jest.fn((key) => (key === "name" ? "Basins" : undefined)),
+ getVisible: jest.fn(() => true),
+ setVisible: jest.fn(),
+ };
+ render(
+ ({ getArray: () => [layer] }) },
+ }}
+ shapefileStatus={shapefileStatus}
+ onRetryShapefile={onRetryShapefile}
+ />,
+ );
+ return screen.findByLabelText("Show Layers Control").then((button) => {
+ fireEvent.click(button);
+ });
+ }
+
+ it("shows an in-flight indication for a loading layer", async () => {
+ await renderControl({ Basins: { state: "loading" } });
+ expect(await screen.findByLabelText("Basins loading")).toBeInTheDocument();
+ });
+
+ it("shows the failure message for a failed layer", async () => {
+ await renderControl({
+ Basins: {
+ state: "error",
+ message: "The shapefile could not be fetched.",
+ kind: ERROR_KIND.FETCH,
+ },
+ });
+ expect(
+ await screen.findByText("The shapefile could not be fetched."),
+ ).toBeInTheDocument();
+ });
+
+ it("offers retry for a fetch-stage failure and calls back with the layer name", async () => {
+ const onRetry = jest.fn();
+ await renderControl(
+ {
+ Basins: {
+ state: "error",
+ message: "unreachable",
+ kind: ERROR_KIND.FETCH,
+ },
+ },
+ onRetry,
+ );
+
+ fireEvent.click(await screen.findByLabelText("Retry Basins"));
+
+ expect(onRetry).toHaveBeenCalledWith("Basins");
+ });
+
+ it.each([
+ [ERROR_KIND.PROJECTION, "a missing or unresolvable coordinate system"],
+ [ERROR_KIND.PARSE, "a malformed component"],
+ [ERROR_KIND.TOO_LARGE, "a source over the size ceiling"],
+ ])("withholds retry for %s (%s)", async (kind) => {
+ // Re-running the same request cannot fix any of these -- they need the
+ // author to change something -- so a retry button would invite a viewer to
+ // re-download megabytes and fail identically.
+ await renderControl(
+ { Basins: { state: "error", message: "nope", kind } },
+ jest.fn(),
+ );
+
+ expect(await screen.findByText("nope")).toBeInTheDocument();
+ expect(screen.queryByLabelText("Retry Basins")).not.toBeInTheDocument();
+ });
+
+ it("renders nothing extra for a layer with no status", async () => {
+ await renderControl({});
+ expect(
+ await screen.findByLabelText("Basins Set Visible"),
+ ).toBeInTheDocument();
+ expect(screen.queryByRole("alert")).not.toBeInTheDocument();
+ });
+});
diff --git a/reactapp/components/map/LayersControl.js b/reactapp/components/map/LayersControl.js
index da86d35c..d7d7e78e 100644
--- a/reactapp/components/map/LayersControl.js
+++ b/reactapp/components/map/LayersControl.js
@@ -1,5 +1,6 @@
import { useContext, useEffect, useState } from "react";
import PropTypes from "prop-types";
+import { isRetryable } from "components/map/layerStatus";
import styled from "styled-components";
import {
FaLayerGroup,
@@ -110,7 +111,13 @@ const CloseButton = styled.button`
right: 5px;
`;
-const LayersControl = ({ updater, visualizationRef, runtimeLayerState }) => {
+const LayersControl = ({
+ updater,
+ visualizationRef,
+ runtimeLayerState,
+ shapefileStatus,
+ onRetryShapefile,
+}) => {
const [layers, setLayers] = useState([]); // [], controls what is shown in the layer controls
const [isexpanded, setisexpanded] = useState(false); // bool, controls layer conrol menu expansion
const [layerVisibility, setLayerVisibility] = useState({}); // {layerName: layerVisibility, ...}, controls checkbox checked value based on layer visibility
@@ -198,6 +205,14 @@ const LayersControl = ({ updater, visualizationRef, runtimeLayerState }) => {
: null;
const progressPct = parseProgress(progressMessage);
const error = isRuntime ? errorsByLayerId[layerId] : undefined;
+ // Client-parsed sources carry their own status, read from the
+ // source rather than from a request id -- there is no backend
+ // request behind them, so the progress channel above never has
+ // anything to report for one.
+ const shapefile = shapefileStatus?.[layerName];
+ const shapefileLoading = shapefile?.state === "loading";
+ const shapefileError =
+ shapefile?.state === "error" ? shapefile : null;
// Hide progress bar once an error is set (error supersedes
// any stale in-progress message) or when it has completed.
const showProgress =
@@ -240,6 +255,41 @@ const LayersControl = ({ updater, visualizationRef, runtimeLayerState }) => {
)}
+ {shapefileLoading && (
+
+
+
+
+
+ )}
+ {shapefileError && (
+
+
+
+ {shapefileError.message}
+
+ {/* Retry only where re-running the same request could
+ succeed. A missing projection, an unresolvable
+ coordinate system, a malformed component and a source
+ over the size ceiling all need the author to change
+ something, so a button here would invite a viewer to
+ re-download megabytes and fail identically. */}
+ {isRetryable(shapefileError.kind) &&
+ onRetryShapefile && (
+ onRetryShapefile(layerName)}
+ aria-label={`Retry ${layerName}`}
+ >
+ Retry
+
+ )}
+
+ )}
{error && (
@@ -289,6 +339,16 @@ LayersControl.propTypes = {
sessionNonce: PropTypes.string,
gridItemUuid: PropTypes.string,
}),
+ // Load state for client-parsed sources, keyed on layer name. These carry no
+ // backend request, so they cannot use the progress channel above.
+ shapefileStatus: PropTypes.objectOf(
+ PropTypes.shape({
+ state: PropTypes.string,
+ message: PropTypes.string,
+ kind: PropTypes.string,
+ }),
+ ),
+ onRetryShapefile: PropTypes.func,
};
export default LayersControl;
diff --git a/reactapp/components/map/Map.js b/reactapp/components/map/Map.js
index 4234c6ca..3605f81e 100644
--- a/reactapp/components/map/Map.js
+++ b/reactapp/components/map/Map.js
@@ -10,7 +10,7 @@ import moduleLoader, {
// matters, because layers are constructed concurrently and a registration that
// waited on anything async would race them.
import { isNativelyResolvable } from "components/map/projections";
-import { CANCEL_REASON } from "components/map/layerStatus";
+import { CANCEL_REASON, errorKindFor } from "components/map/layerStatus";
import LayersControl from "components/map/LayersControl";
import FloatingMapControl from "components/map/FloatingMapControl";
import LegendControl from "components/map/LegendControl";
@@ -93,6 +93,31 @@ async function applyLayerStyle(olLayer, layerConfig) {
}
}
+// Mirror a shapefile source's load state into React state so it can be
+// rendered. The events are the only signal available: featuresloaderror carries
+// no payload, so the typed failure is read off the controller when it fires.
+function watchShapefileLoad(olLayer, layerName, setStatus) {
+ const source = olLayer?.getSource?.();
+ const controller = source?.get?.("shapefileController");
+ if (!controller) return;
+
+ const sync = () => {
+ const failure = controller.getError();
+ setStatus((previous) => ({
+ ...previous,
+ [layerName]: {
+ state: controller.getStatus(),
+ message: failure?.detail ?? null,
+ kind: failure ? errorKindFor(failure) : null,
+ },
+ }));
+ };
+
+ source.on("featuresloadstart", sync);
+ source.on("featuresloadend", sync);
+ source.on("featuresloaderror", sync);
+}
+
// Stop an in-flight shapefile load. Called when the layer is going away, so the
// fetch and decompression do not keep running for a layer nobody will see.
function abortShapefileLoad(olLayer, reason) {
@@ -115,6 +140,10 @@ const MapComponent = ({
runtimeLayerState,
}) => {
const [errorMessage, setErrorMessage] = useState("");
+ // Per-layer load state for client-parsed sources, keyed on layer name.
+ // Mirrored into React state purely so it can be rendered; the source's own
+ // controller remains the authority.
+ const [shapefileStatus, setShapefileStatus] = useState({});
const [layerControlUpdate, setLayerControlUpdate] = useState();
const mapDivRef = useRef();
const onMapClickCurrent = useRef();
@@ -157,6 +186,35 @@ const MapComponent = ({
rafId = requestAnimationFrame(step);
};
+ // Surfaced here rather than only in the layers control, which is opt-in per
+ // dashboard and collapsed to an icon by default. Routing status only there
+ // would leave a viewer with nothing at all on any dashboard whose author
+ // disabled it -- and a failure that renders as a blank layer is the one thing
+ // this must not do. The layers control still carries the richer per-layer
+ // detail when it is enabled.
+ const shapefileEntries = Object.entries(shapefileStatus);
+ const shapefileFailures = shapefileEntries.filter(
+ ([, status]) => status.state === "error",
+ );
+ const shapefileLoading = shapefileEntries.filter(
+ ([, status]) => status.state === "loading",
+ );
+ const shapefileAlert = shapefileFailures.length
+ ? {
+ variant: "danger",
+ message: shapefileFailures
+ .map(([name, status]) => `${name}: ${status.message}`)
+ .join(" "),
+ }
+ : shapefileLoading.length
+ ? {
+ variant: "info",
+ message: `Loading ${shapefileLoading
+ .map(([name]) => name)
+ .join(", ")}\u2026`,
+ }
+ : null;
+
const defaultMapConfig = {
className: "ol-map",
style: { width: "100%", height: "100%", position: "relative" },
@@ -546,6 +604,7 @@ const MapComponent = ({
}
newLayer.set("appliedStyle", layerConfig.style);
map.addLayer(newLayer);
+ watchShapefileLoad(newLayer, name, setShapefileStatus);
if (
layerConfig.type === "WebGLTile" &&
@@ -807,6 +866,24 @@ const MapComponent = ({
// run can't overwrite it with a stale config.
if (!superseded) {
currentLayers.current = layers ?? [];
+
+ // Drop status for layers no longer on the map, so a rebuilt layer never
+ // shows the previous instance's failure -- and a stale error never
+ // suppresses the replacement's loading indication.
+ const liveNames = new Set(
+ map
+ .getLayers()
+ .getArray()
+ .map((layer) => layer.get("name")),
+ );
+ setShapefileStatus((previous) => {
+ const kept = Object.fromEntries(
+ Object.entries(previous).filter(([name]) => liveNames.has(name)),
+ );
+ return Object.keys(kept).length === Object.keys(previous).length
+ ? previous
+ : kept;
+ });
}
};
@@ -845,6 +922,17 @@ const MapComponent = ({
)}
+ {shapefileAlert && (
+
+
+ {shapefileAlert.message}
+
+
+ )}
{dataviewerViz && (
Zoom: {zoom}
@@ -869,6 +957,14 @@ const MapComponent = ({
visualizationRef={visualizationRef}
updater={layerControlUpdate}
runtimeLayerState={runtimeLayerState}
+ shapefileStatus={shapefileStatus}
+ onRetryShapefile={(layerName) => {
+ const layer = visualizationRef.current
+ ?.getLayers()
+ .getArray()
+ .find((candidate) => candidate.get("name") === layerName);
+ layer?.getSource?.()?.get?.("shapefileController")?.reset?.();
+ }}
/>
)}
{legend && legend.length > 0 && }
From 2d495e813ad90069194d4b80da73e0d229f6bef0 Mon Sep 17 00:00:00 2001
From: Corey Krewson
Date: Tue, 25 Aug 2026 11:29:44 -0700
Subject: [PATCH 09/18] feat(map): read shapefile fields on request, once, and
report drifted references
Field discovery for a shapefile is triggered by the author rather than run
automatically. The style pane's own discovery effect re-runs whenever its source
props change, which for a typed URL means once per keystroke -- and each run
here is a multi-megabyte download. The editor already established this pattern
for remote GeoJSON with an explicit load action.
One read serves both panes. Results are memoized against the resolved URL, and
the style and attributes panes read from that instead of each fetching. The
attributes pane's automatic read is suppressed for this source type the same way
it already is for remote GeoJSON.
Variable-input templates are resolved before fetching. The editor holds the raw
configuration, so a templated URL would otherwise be requested literally --
guaranteed to fail, for exactly the sources variable inputs are most useful for.
Storing no schema keeps the field list true to the source, but it moves
staleness into the rules that name those fields: an upstream rename leaves them
matching nothing while the layer still renders, so nothing fails and nobody is
told. Discovery now compares what it found against every field the saved
configuration references -- style rules including nested conditions, popup
configuration, and attribute variables -- and names the ones that have gone
missing. Field references are collected by walking the configuration rather than
by known path, so a reference that moves does not silently stop being checked.
The drift list renders in the Source tab, beside the action that produced it,
rather than split across the panes that hold the settings. A style-rule
reference that had gone missing would otherwise be invisible to an author who
never opens the Attributes tab.
Two things carried over from the surfacing unit, because they attach to this
action: a fetch-stage failure names converting to GeoJSON as the supported
alternative -- upload is not offered and a proxy is out of scope, so without it
the author is told the cause and left with no move -- and a pending read past a
fixed threshold escalates its message, since an indicator that never changes
reads as a hang and invites paying for the read twice. A failure also states
that saved style, popup and attribute settings are untouched.
Discovery is held by the modal, which already hoists every pane's state, so no
new context was needed to share it.
Co-Authored-By: Claude Opus 5 (1M context)
---
.../MapLayer/shapefileDiscovery.test.js | 308 ++++++++++++++++++
.../MapLayer/shapefileSourcePanel.test.js | 183 +++++++++++
.../modals/MapLayer/AttributesPane.js | 25 ++
.../components/modals/MapLayer/MapLayer.js | 18 +
.../components/modals/MapLayer/SourcePane.js | 105 ++++++
.../components/modals/MapLayer/StylePane.js | 25 +-
.../modals/MapLayer/shapefileDiscovery.js | 215 ++++++++++++
7 files changed, 878 insertions(+), 1 deletion(-)
create mode 100644 reactapp/__tests__/components/modals/MapLayer/shapefileDiscovery.test.js
create mode 100644 reactapp/__tests__/components/modals/MapLayer/shapefileSourcePanel.test.js
create mode 100644 reactapp/components/modals/MapLayer/shapefileDiscovery.js
diff --git a/reactapp/__tests__/components/modals/MapLayer/shapefileDiscovery.test.js b/reactapp/__tests__/components/modals/MapLayer/shapefileDiscovery.test.js
new file mode 100644
index 00000000..adafbc77
--- /dev/null
+++ b/reactapp/__tests__/components/modals/MapLayer/shapefileDiscovery.test.js
@@ -0,0 +1,308 @@
+import { renderHook, act } from "@testing-library/react";
+import {
+ useShapefileDiscovery,
+ resolveShapefileUrl,
+ collectReferencedFields,
+ SLOW_LOAD_MS,
+} from "components/modals/MapLayer/shapefileDiscovery";
+import { acquireComponents } from "components/map/shapefile/acquire";
+import { interpretShapefile } from "components/map/shapefile/index";
+
+jest.mock("components/map/shapefile/acquire", () => ({
+ acquireComponents: jest.fn(),
+}));
+jest.mock("components/map/shapefile/index", () => ({
+ interpretShapefile: jest.fn(),
+}));
+
+const SOURCE = {
+ type: "Shapefile",
+ props: { url: "https://example.org/basins.zip" },
+};
+
+function collection(propertySets) {
+ return {
+ featureCollection: {
+ type: "FeatureCollection",
+ features: propertySets.map((properties) => ({
+ type: "Feature",
+ properties,
+ geometry: { type: "Point", coordinates: [0, 0] },
+ })),
+ },
+ projectionCode: "EPSG:4326",
+ };
+}
+
+function setup(overrides = {}) {
+ return renderHook((props) => useShapefileDiscovery(props), {
+ initialProps: {
+ sourceProps: SOURCE,
+ layerName: "Basins",
+ variableInputValues: {},
+ variableInputDateFormats: {},
+ ...overrides,
+ },
+ });
+}
+
+beforeEach(() => {
+ acquireComponents.mockReset();
+ interpretShapefile.mockReset();
+ acquireComponents.mockResolvedValue({
+ components: { shp: new Uint8Array() },
+ });
+ interpretShapefile.mockResolvedValue(
+ collection([
+ { HUC8: "1", AREASQKM: 2 },
+ { HUC8: "2", STATES: "CO" },
+ ]),
+ );
+});
+
+describe("resolveShapefileUrl", () => {
+ it("returns a plain url unchanged", () => {
+ expect(resolveShapefileUrl({ sourceProps: SOURCE })).toBe(
+ "https://example.org/basins.zip",
+ );
+ });
+
+ it("substitutes a variable-input template", () => {
+ // The editor holds the raw configuration, so a templated url arrives here
+ // unsubstituted -- and fetching it literally is guaranteed to fail, which
+ // would make discovery unusable for exactly the sources variable inputs are
+ // most useful for.
+ const resolved = resolveShapefileUrl({
+ sourceProps: {
+ type: "Shapefile",
+ // eslint-disable-next-line no-template-curly-in-string
+ props: { url: "https://example.org/${Basin}.zip" },
+ },
+ variableInputValues: { Basin: "upper-colorado" },
+ variableInputDateFormats: {},
+ });
+ expect(resolved).toBe("https://example.org/upper-colorado.zip");
+ });
+
+ it("returns null for a source with no url", () => {
+ expect(
+ resolveShapefileUrl({ sourceProps: { type: "Shapefile", props: {} } }),
+ ).toBeNull();
+ });
+});
+
+describe("collectReferencedFields", () => {
+ it("finds fields named by style rules, including nested conditions", () => {
+ const referenced = collectReferencedFields({
+ style: {
+ rules: [
+ { conditionField: "HUC8", conditions: [{ field: "AREASQKM" }] },
+ { conditionField: "" },
+ ],
+ },
+ });
+ expect(Array.from(referenced).sort()).toEqual(["AREASQKM", "HUC8"]);
+ });
+
+ it("finds fields bound to attribute variables and omitted from popups", () => {
+ const referenced = collectReferencedFields({
+ attributeProps: {
+ variables: { Basins: { GAGE_ID: "Selected Gage" } },
+ omitted: { Basins: ["SHAPE_LEN"] },
+ },
+ });
+ expect(Array.from(referenced).sort()).toEqual(["GAGE_ID", "SHAPE_LEN"]);
+ });
+
+ it("returns nothing for an empty configuration", () => {
+ expect(collectReferencedFields({}).size).toBe(0);
+ });
+});
+
+describe("useShapefileDiscovery", () => {
+ it("does not read the source until the author asks", async () => {
+ const { result } = setup();
+ expect(result.current.state).toBe("idle");
+ expect(acquireComponents).not.toHaveBeenCalled();
+ });
+
+ it("does not read the source when props change", async () => {
+ // This is the whole point of being author-triggered: the style pane's own
+ // effect re-runs on every source-props change, which for a typed url is once
+ // per keystroke -- and each run is a multi-megabyte download.
+ const { rerender } = setup();
+ rerender({
+ sourceProps: {
+ type: "Shapefile",
+ props: { url: "https://example.org/basins.zi" },
+ },
+ layerName: "Basins",
+ variableInputValues: {},
+ variableInputDateFormats: {},
+ });
+ rerender({
+ sourceProps: SOURCE,
+ layerName: "Basins",
+ variableInputValues: {},
+ variableInputDateFormats: {},
+ });
+ expect(acquireComponents).not.toHaveBeenCalled();
+ });
+
+ it("returns the union of field names once loaded", async () => {
+ const { result } = setup();
+ await act(async () => {
+ await result.current.load();
+ });
+ expect(result.current.state).toBe("ready");
+ expect(result.current.fields.sort()).toEqual([
+ "AREASQKM",
+ "HUC8",
+ "STATES",
+ ]);
+ });
+
+ it("serves a second read of the same url from memory", async () => {
+ const { result } = setup();
+ await act(async () => {
+ await result.current.load();
+ });
+ await act(async () => {
+ await result.current.load();
+ });
+ expect(acquireComponents).toHaveBeenCalledTimes(1);
+ expect(result.current.fields).toHaveLength(3);
+ });
+
+ it("reads again when the resolved url changes", async () => {
+ const { result, rerender } = setup();
+ await act(async () => {
+ await result.current.load();
+ });
+
+ rerender({
+ sourceProps: {
+ type: "Shapefile",
+ props: { url: "https://example.org/gages.zip" },
+ },
+ layerName: "Basins",
+ variableInputValues: {},
+ variableInputDateFormats: {},
+ });
+ await act(async () => {
+ await result.current.load();
+ });
+
+ expect(acquireComponents).toHaveBeenCalledTimes(2);
+ });
+
+ it("names the convert-to-GeoJSON alternative on a fetch-stage failure", async () => {
+ // Upload is not offered and a proxy is out of scope, so without this the
+ // author is told the cause and left with no move.
+ acquireComponents.mockResolvedValue({
+ error: {
+ stage: "fetch",
+ reason: "unreachable",
+ detail: "The shapefile could not be fetched.",
+ },
+ });
+ const { result } = setup();
+
+ await act(async () => {
+ await result.current.load();
+ });
+
+ expect(result.current.state).toBe("error");
+ expect(result.current.failure.detail).toMatch(/could not be fetched/);
+ expect(result.current.failure.remedy).toMatch(/convert the shapefile/i);
+ });
+
+ it("does not offer that alternative for a failure it would not fix", async () => {
+ interpretShapefile.mockResolvedValue({
+ error: {
+ stage: "parse",
+ reason: "missing_projection",
+ detail: "no projection",
+ },
+ });
+ const { result } = setup();
+
+ await act(async () => {
+ await result.current.load();
+ });
+
+ expect(result.current.failure.remedy).toBeNull();
+ });
+
+ it("escalates a pending read past the threshold", async () => {
+ jest.useFakeTimers();
+ acquireComponents.mockImplementation(() => new Promise(() => {}));
+ const { result } = setup();
+
+ act(() => {
+ result.current.load();
+ });
+ expect(result.current.slow).toBe(false);
+
+ act(() => {
+ jest.advanceTimersByTime(SLOW_LOAD_MS + 1);
+ });
+ // A pending indicator that never changes reads as a hang, and the author
+ // retriggers the load and pays for it twice.
+ expect(result.current.slow).toBe(true);
+ jest.useRealTimers();
+ });
+
+ it("reports fields the saved configuration names but the source lacks", async () => {
+ // Storing no schema keeps the field list true to the source, but moves
+ // staleness into the rules naming those fields: an upstream rename leaves
+ // them matching nothing while the layer still renders, so nothing fails.
+ const { result } = setup({
+ style: { rules: [{ conditionField: "POP2020" }] },
+ attributeProps: { variables: { Basins: { GAGE_ID: "Gage" } } },
+ });
+
+ await act(async () => {
+ await result.current.load();
+ });
+
+ expect(result.current.drift).toEqual(["GAGE_ID", "POP2020"]);
+ });
+
+ it("reports no drift when every referenced field is present", async () => {
+ const { result } = setup({
+ style: { rules: [{ conditionField: "HUC8" }] },
+ });
+ await act(async () => {
+ await result.current.load();
+ });
+ expect(result.current.drift).toEqual([]);
+ });
+
+ it("reports no drift before a read has succeeded", async () => {
+ const { result } = setup({
+ style: { rules: [{ conditionField: "POP2020" }] },
+ });
+ expect(result.current.drift).toEqual([]);
+ });
+
+ it("is inert for a non-shapefile source", async () => {
+ const { result } = setup({ sourceProps: { type: "GeoJSON", props: {} } });
+ expect(result.current.isShapefile).toBe(false);
+ expect(result.current.resolvedUrl).toBeNull();
+ await act(async () => {
+ await result.current.load();
+ });
+ expect(acquireComponents).not.toHaveBeenCalled();
+ });
+
+ it("waits for the cancelled state without reporting a failure", async () => {
+ acquireComponents.mockResolvedValue({ cancelled: true });
+ const { result } = setup();
+ await act(async () => {
+ await result.current.load();
+ });
+ expect(result.current.state).toBe("idle");
+ expect(result.current.failure).toBeNull();
+ });
+});
diff --git a/reactapp/__tests__/components/modals/MapLayer/shapefileSourcePanel.test.js b/reactapp/__tests__/components/modals/MapLayer/shapefileSourcePanel.test.js
new file mode 100644
index 00000000..8ab71790
--- /dev/null
+++ b/reactapp/__tests__/components/modals/MapLayer/shapefileSourcePanel.test.js
@@ -0,0 +1,183 @@
+import { render, screen, fireEvent } from "@testing-library/react";
+import SourcePane from "components/modals/MapLayer/SourcePane";
+import {
+ AppContext,
+ LayoutContext,
+ VariableInputsContext,
+} from "components/contexts/Contexts";
+import MapContextProvider from "components/contexts/MapContext";
+
+const SOURCE_PROPS = {
+ type: "Shapefile",
+ props: { url: "https://example.org/basins.zip" },
+};
+
+function discovery(overrides = {}) {
+ return {
+ isShapefile: true,
+ resolvedUrl: "https://example.org/basins.zip",
+ state: "idle",
+ slow: false,
+ fields: [],
+ failure: null,
+ drift: [],
+ load: jest.fn(),
+ ...overrides,
+ };
+}
+
+function renderPane(shapefileDiscovery) {
+ render(
+
+
+
+
+
+
+
+
+ ,
+ );
+}
+
+describe("shapefile discovery panel", () => {
+ it("offers an explicit read action rather than reading on its own", async () => {
+ // The style pane's own discovery effect re-runs on every source-props
+ // change, which for a typed url is once per keystroke. Each read here is a
+ // multi-megabyte download, so it has to be asked for.
+ const state = discovery();
+ renderPane(state);
+
+ const button = await screen.findByLabelText("Read shapefile fields");
+ expect(state.load).not.toHaveBeenCalled();
+
+ fireEvent.click(button);
+ expect(state.load).toHaveBeenCalled();
+ });
+
+ it("disables the action while a read is in flight", async () => {
+ renderPane(discovery({ state: "loading" }));
+ expect(
+ await screen.findByLabelText("Read shapefile fields"),
+ ).toBeDisabled();
+ });
+
+ it("disables the action when there is no url to read", async () => {
+ renderPane(discovery({ resolvedUrl: null }));
+ expect(
+ await screen.findByLabelText("Read shapefile fields"),
+ ).toBeDisabled();
+ });
+
+ it("escalates the message once a read passes the threshold", async () => {
+ renderPane(discovery({ state: "loading", slow: true }));
+ expect(await screen.findByRole("status")).toHaveTextContent(
+ /still reading/i,
+ );
+ });
+
+ it("lists the fields it found", async () => {
+ renderPane(discovery({ state: "ready", fields: ["HUC8", "AREASQKM"] }));
+ expect(await screen.findByText(/Found 2 fields/)).toBeInTheDocument();
+ expect(screen.getByText(/HUC8, AREASQKM/)).toBeInTheDocument();
+ });
+
+ it("reports a failure and names what the author can do about it", async () => {
+ renderPane(
+ discovery({
+ state: "error",
+ failure: {
+ detail: "The shapefile could not be fetched.",
+ remedy: "Convert the shapefile to GeoJSON and use that source.",
+ },
+ }),
+ );
+
+ const alert = await screen.findByRole("alert");
+ expect(alert).toHaveTextContent("could not be fetched");
+ // Upload is not offered and a proxy is out of scope, so without naming an
+ // alternative the author is told the cause and left with no move.
+ expect(alert).toHaveTextContent("Convert the shapefile to GeoJSON");
+ // And their existing configuration is explicitly said to be intact.
+ expect(alert).toHaveTextContent(/unchanged/i);
+ });
+
+ it("omits the alternative for a failure it would not fix", async () => {
+ renderPane(
+ discovery({
+ state: "error",
+ failure: { detail: "No projection was supplied.", remedy: null },
+ }),
+ );
+
+ const alert = await screen.findByRole("alert");
+ expect(alert).toHaveTextContent("No projection");
+ expect(alert).not.toHaveTextContent("Convert the shapefile");
+ });
+
+ it("names saved field references the source no longer has", async () => {
+ // Rendered next to the action that produced it rather than split across the
+ // style and attributes panes: a style-rule reference that had gone missing
+ // would otherwise be invisible to an author who never opens Attributes.
+ renderPane(
+ discovery({
+ state: "ready",
+ fields: ["HUC8"],
+ drift: ["POP2020", "SHAPE_LEN"],
+ }),
+ );
+
+ const alerts = await screen.findAllByRole("alert");
+ const drift = alerts.find((node) =>
+ node.textContent.includes("does not have"),
+ );
+ expect(drift).toHaveTextContent("POP2020, SHAPE_LEN");
+ expect(drift).toHaveTextContent(/will not match anything/i);
+ });
+
+ it("says nothing about drift when every referenced field is present", async () => {
+ renderPane(discovery({ state: "ready", fields: ["HUC8"], drift: [] }));
+ expect(await screen.findByText(/Found 1 field/)).toBeInTheDocument();
+ expect(screen.queryByText(/does not have/)).not.toBeInTheDocument();
+ });
+
+ it("renders no panel for a non-shapefile source", async () => {
+ render(
+
+
+
+
+
+
+
+
+ ,
+ );
+
+ expect(
+ screen.queryByLabelText("Read shapefile fields"),
+ ).not.toBeInTheDocument();
+ });
+});
diff --git a/reactapp/components/modals/MapLayer/AttributesPane.js b/reactapp/components/modals/MapLayer/AttributesPane.js
index dce369b6..b2bf4d0c 100644
--- a/reactapp/components/modals/MapLayer/AttributesPane.js
+++ b/reactapp/components/modals/MapLayer/AttributesPane.js
@@ -71,6 +71,7 @@ const AttributesPane = ({
sourceProps,
layerProps,
tabKey,
+ shapefileDiscovery,
}) => {
const [warningMessage, setWarningMessage] = useState(null);
const [errorMessage, setErrorMessage] = useState(null);
@@ -155,6 +156,23 @@ const AttributesPane = ({
return;
}
+ // A shapefile's fields come from the shared, author-triggered read in
+ // the Source tab, so opening this tab must not start a multi-megabyte
+ // download of its own. Whatever that read found is applied below.
+ if (shapefileDiscovery?.isShapefile) {
+ applyLayerAttributes(
+ shapefileDiscovery.state === "ready"
+ ? {
+ [layerProps.name]: shapefileDiscovery.fields.map((field) => ({
+ name: field,
+ alias: field,
+ })),
+ }
+ : null,
+ );
+ return;
+ }
+
// query attributes from the source props url
queryLayerAttributes().then(applyLayerAttributes);
}
@@ -664,6 +682,13 @@ const AttributesPane = ({
};
AttributesPane.propTypes = {
+ // Shared, author-triggered field discovery for a shapefile source. Supplied by
+ // the modal so this pane and the Style pane read one result between them.
+ shapefileDiscovery: PropTypes.shape({
+ isShapefile: PropTypes.bool,
+ state: PropTypes.string,
+ fields: PropTypes.arrayOf(PropTypes.string),
+ }),
attributeProps: attributePropsPropType, // react state that tracks attribute properties
setAttributeProps: PropTypes.func, // state setter for attributeProps
sourceProps: sourcePropType, // configuration and properties for openlayers layer source
diff --git a/reactapp/components/modals/MapLayer/MapLayer.js b/reactapp/components/modals/MapLayer/MapLayer.js
index 63468d97..e0772b28 100644
--- a/reactapp/components/modals/MapLayer/MapLayer.js
+++ b/reactapp/components/modals/MapLayer/MapLayer.js
@@ -1,4 +1,5 @@
import PropTypes from "prop-types";
+import { useShapefileDiscovery } from "components/modals/MapLayer/shapefileDiscovery";
import Modal from "react-bootstrap/Modal";
import styled from "styled-components";
import Button from "react-bootstrap/Button";
@@ -176,6 +177,20 @@ const MapLayerModal = ({
);
const mapContext = useMapContext();
+ // Field discovery for a shapefile source, held here rather than in either pane
+ // because both read from it. The modal already hoists every pane's state, so a
+ // new context would buy nothing -- and one read serves both panes instead of
+ // each paying for its own download.
+ const shapefileDiscovery = useShapefileDiscovery({
+ sourceProps,
+ layerName: layerProps?.name,
+ variableInputValues,
+ variableInputDateFormats,
+ style,
+ attributeProps,
+ popupConfig,
+ });
+
const onRequestHideModal = useCallback(() => {
setHiddenForExtentDraw(true);
}, []);
@@ -662,6 +677,7 @@ const MapLayerModal = ({
setErrorMessage={setErrorMessage}
onRequestHideModal={onRequestHideModal}
onFetchPluginDefaults={fetchPluginDefaults}
+ shapefileDiscovery={shapefileDiscovery}
/>
@@ -709,6 +726,7 @@ const MapLayerModal = ({
sourceProps={sourceProps}
layerProps={layerProps}
tabKey={tabKey}
+ shapefileDiscovery={shapefileDiscovery}
/>
{
+ const { state, slow, fields, failure, drift, load } = discovery;
+
+ return (
+
+
+
+ Reads the source once so the Style and Attributes tabs can offer its
+ fields.
+
+
+ {state === "loading" && slow && (
+
+
+ Still reading this shapefile — large archives can take a
+ while.
+
+
+ );
+};
+
+ShapefileDiscoveryPanel.propTypes = {
+ discovery: PropTypes.shape({
+ state: PropTypes.string,
+ slow: PropTypes.bool,
+ fields: PropTypes.arrayOf(PropTypes.string),
+ failure: PropTypes.shape({
+ detail: PropTypes.string,
+ remedy: PropTypes.string,
+ }),
+ drift: PropTypes.arrayOf(PropTypes.string),
+ load: PropTypes.func,
+ resolvedUrl: PropTypes.string,
+ }).isRequired,
+};
+
const SourcePane = ({
sourceProps,
setSourceProps,
@@ -105,6 +205,7 @@ const SourcePane = ({
setErrorMessage,
onRequestHideModal,
onFetchPluginDefaults,
+ shapefileDiscovery,
}) => {
const [sourceProperties, setSourceProperties] = useState([]); // array of objects that represent properties that will be rendered in the table
const [propertyPlaceholders, SetPropertyPlaceholders] = useState([]); // array of objects that represent placeholders for the table inputs
@@ -506,6 +607,9 @@ const SourcePane = ({
>
)}
+ {sourceType.value === "Shapefile" && shapefileDiscovery && (
+
+ )}
{sourceType.value === "Static Image" &&
mapContext &&
onRequestHideModal && (
@@ -527,6 +631,7 @@ const SourcePane = ({
};
SourcePane.propTypes = {
+ shapefileDiscovery: ShapefileDiscoveryPanel.propTypes.discovery,
sourceProps: sourcePropType,
setSourceProps: PropTypes.func, // setter for sourceProps state
setStyle: PropTypes.func, // setter for style state (used by Fetch defaults applied from MapLayer)
diff --git a/reactapp/components/modals/MapLayer/StylePane.js b/reactapp/components/modals/MapLayer/StylePane.js
index bea34222..05317cbc 100644
--- a/reactapp/components/modals/MapLayer/StylePane.js
+++ b/reactapp/components/modals/MapLayer/StylePane.js
@@ -87,6 +87,7 @@ const StylePane = ({
sourceProps,
setSourceProps,
layerProps,
+ shapefileDiscovery,
}) => {
const [styleSource, setStyleSource] = useState("custom"); // track the geojson value
const [styleMode, setStyleMode] = useState("json"); // "json" or "rules"
@@ -97,6 +98,15 @@ const StylePane = ({
const { dynamicMapLayers } = useContext(AppContext);
useEffect(() => {
+ // A shapefile's fields come from the shared, author-triggered discovery
+ // instead. This effect re-runs on every source-props change -- which for a
+ // typed url is once per keystroke -- and each run there is a multi-megabyte
+ // download.
+ if (shapefileDiscovery?.isShapefile) {
+ setAvailableFields(shapefileDiscovery.fields);
+ return;
+ }
+
const isDynamic = !!findSelectOptionByValue(
dynamicMapLayers,
sourceProps.type,
@@ -115,7 +125,14 @@ const StylePane = ({
}
};
fetchAvailableFields();
- }, [sourceProps, layerProps, uuid, dynamicMapLayers]);
+ }, [
+ sourceProps,
+ layerProps,
+ uuid,
+ dynamicMapLayers,
+ shapefileDiscovery?.isShapefile,
+ shapefileDiscovery?.fields,
+ ]);
useEffect(() => {
if (
@@ -531,6 +548,12 @@ const StylePane = ({
};
StylePane.propTypes = {
+ // Shared, author-triggered field discovery for a shapefile source. Supplied by
+ // the modal so both panes read one result.
+ shapefileDiscovery: PropTypes.shape({
+ isShapefile: PropTypes.bool,
+ fields: PropTypes.arrayOf(PropTypes.string),
+ }),
style: PropTypes.string, // stringified json for styling layer
setStyle: PropTypes.func,
setErrorMessage: PropTypes.func,
diff --git a/reactapp/components/modals/MapLayer/shapefileDiscovery.js b/reactapp/components/modals/MapLayer/shapefileDiscovery.js
new file mode 100644
index 00000000..88085010
--- /dev/null
+++ b/reactapp/components/modals/MapLayer/shapefileDiscovery.js
@@ -0,0 +1,215 @@
+import { useCallback, useMemo, useRef, useState } from "react";
+import { updateObjectWithVariableInputs } from "components/visualizations/utilities";
+import { acquireComponents } from "components/map/shapefile/acquire";
+import { interpretShapefile } from "components/map/shapefile/index";
+import { errorKindFor, ERROR_KIND } from "components/map/layerStatus";
+
+// How long a pending read may run before the message escalates. A shapefile can
+// take many seconds legitimately, and an indicator that never changes reads as a
+// hang -- so the author retriggers the load and pays for it twice.
+export const SLOW_LOAD_MS = 8000;
+
+// What an author can actually do when the source cannot be fetched. Upload is
+// not offered and a proxy is out of scope, so without naming this the author is
+// told the cause and left with no move.
+const FETCH_REMEDY =
+ "If the host cannot be reached from a browser, convert the shapefile to GeoJSON and use the GeoJSON source instead.";
+
+/**
+ * Resolve a source URL for use in the editor.
+ *
+ * The editor holds the raw configuration, so a URL carrying a variable-input
+ * template arrives here unsubstituted. Fetching it literally is guaranteed to
+ * fail, which would make field discovery unusable for exactly the sources
+ * variable inputs are most useful for.
+ */
+export function resolveShapefileUrl({
+ sourceProps,
+ variableInputValues,
+ variableInputDateFormats,
+}) {
+ const url = sourceProps?.props?.url;
+ if (typeof url !== "string" || url === "") return null;
+ if (!url.includes("${")) return url;
+
+ try {
+ const substituted = updateObjectWithVariableInputs({
+ args: { url },
+ variableInputs: variableInputValues ?? {},
+ variableInputDateFormats: variableInputDateFormats ?? {},
+ });
+ return substituted?.url ?? url;
+ } catch {
+ return url;
+ }
+}
+
+// Every field name the saved configuration depends on, wherever it is recorded.
+// Walked generically rather than by known path: rules nest conditions, and a
+// field reference that moved would otherwise silently stop being checked.
+export function collectReferencedFields({
+ style,
+ attributeProps,
+ popupConfig,
+}) {
+ const referenced = new Set();
+
+ const walk = (node) => {
+ if (!node || typeof node !== "object") return;
+ if (Array.isArray(node)) {
+ node.forEach(walk);
+ return;
+ }
+ Object.entries(node).forEach(([key, value]) => {
+ if (
+ (key === "conditionField" || key === "field") &&
+ typeof value === "string" &&
+ value !== ""
+ ) {
+ referenced.add(value);
+ } else {
+ walk(value);
+ }
+ });
+ };
+
+ walk(style);
+ walk(popupConfig);
+
+ // Attribute variables are keyed by field name, and omitted popup attributes
+ // are lists of them.
+ Object.values(attributeProps?.variables ?? {}).forEach((byField) =>
+ Object.keys(byField ?? {}).forEach((field) => referenced.add(field)),
+ );
+ Object.values(attributeProps?.omitted ?? {}).forEach((list) =>
+ (list ?? []).forEach((field) => referenced.add(field)),
+ );
+
+ return referenced;
+}
+
+/**
+ * Author-triggered field discovery for a shapefile source.
+ *
+ * Not automatic. The style pane's own discovery effect re-runs whenever its
+ * source props change, which for a typed URL means once per keystroke -- and
+ * each run here is a multi-megabyte download. The editor already established
+ * this pattern for remote GeoJSON with an explicit load action.
+ *
+ * Results are memoized against the resolved URL, so the style pane and the
+ * attributes pane reading in turn cost one read between them.
+ */
+export function useShapefileDiscovery({
+ sourceProps,
+ layerName,
+ variableInputValues,
+ variableInputDateFormats,
+ style,
+ attributeProps,
+ popupConfig,
+}) {
+ const [state, setState] = useState("idle");
+ const [slow, setSlow] = useState(false);
+ const [fields, setFields] = useState([]);
+ const [failure, setFailure] = useState(null);
+ const byUrl = useRef(new Map());
+
+ const isShapefile = sourceProps?.type === "Shapefile";
+ const resolvedUrl = isShapefile
+ ? resolveShapefileUrl({
+ sourceProps,
+ variableInputValues,
+ variableInputDateFormats,
+ })
+ : null;
+
+ const load = useCallback(async () => {
+ if (!resolvedUrl) return;
+
+ const cached = byUrl.current.get(resolvedUrl);
+ if (cached) {
+ setFields(cached);
+ setFailure(null);
+ setState("ready");
+ return;
+ }
+
+ setState("loading");
+ setSlow(false);
+ setFailure(null);
+ const slowTimer = setTimeout(() => setSlow(true), SLOW_LOAD_MS);
+
+ const report = (error) => {
+ const kind = errorKindFor(error);
+ setFailure({
+ detail: error.detail,
+ remedy: kind === ERROR_KIND.FETCH ? FETCH_REMEDY : null,
+ });
+ // Saved style rules, popup settings and attribute variables are left
+ // exactly as they are: the source being unreachable says nothing about
+ // whether the author's configuration is right.
+ setState("error");
+ };
+
+ try {
+ const acquired = await acquireComponents(resolvedUrl);
+ if (acquired.cancelled) {
+ setState("idle");
+ return;
+ }
+ if (acquired.error) {
+ report(acquired.error);
+ return;
+ }
+
+ const interpreted = await interpretShapefile(acquired.components, {
+ fallbackProjection: sourceProps?.props?.projection,
+ });
+ if (interpreted.error) {
+ report(interpreted.error);
+ return;
+ }
+
+ const discovered = Array.from(
+ new Set(
+ (interpreted.featureCollection.features ?? []).flatMap((feature) =>
+ Object.keys(feature.properties ?? {}),
+ ),
+ ),
+ );
+ byUrl.current.set(resolvedUrl, discovered);
+ setFields(discovered);
+ setState("ready");
+ } finally {
+ clearTimeout(slowTimer);
+ setSlow(false);
+ }
+ }, [resolvedUrl, sourceProps?.props?.projection]);
+
+ // Field names the saved configuration references that the source does not
+ // have. Storing no schema keeps the field list true to the source, but it
+ // moves staleness into the rules that name those fields -- an upstream rename
+ // leaves them matching nothing while the layer still renders, so nothing
+ // fails and nobody is told.
+ const drift = useMemo(() => {
+ if (state !== "ready") return [];
+ const available = new Set(fields);
+ return Array.from(
+ collectReferencedFields({ style, attributeProps, popupConfig }),
+ )
+ .filter((field) => !available.has(field))
+ .sort();
+ }, [state, fields, style, attributeProps, popupConfig]);
+
+ return {
+ isShapefile,
+ resolvedUrl,
+ state,
+ slow,
+ fields,
+ failure,
+ drift,
+ layerName,
+ load,
+ };
+}
From 25a947fab03f40ac3eb45683dddf13f2801f0871 Mon Sep 17 00:00:00 2001
From: Corey Krewson
Date: Tue, 25 Aug 2026 12:18:01 -0700
Subject: [PATCH 10/18] fix(map): absent fields no longer match negated rules
or leave stale variables
Three pre-existing defects in shared code, corrected together because a
shapefile exposes all three and none is specific to it. Each changes behavior
for every styled vector layer, so present-value behavior is covered by
regression tests alongside.
A field the feature does not carry can no longer satisfy a comparison. The
negated operators were inverting into a match -- `!=` became `undefined !== x`,
`notIn` became "not in the list", both true -- so one saved rule repainted every
feature of a layer whose .dbf was missing or whose schema drifted upstream. The
layer still rendered, so nothing failed and nobody was told. The presence checks
run first and are untouched: asking whether an absent field is null has a real
answer, and a rule styling "no data" depends on it. An empty string stays a
present value.
An attribute variable whose bound field is absent is now cleared rather than
left alone. Skipping the write left the variable holding the previously clicked
feature's value, so every dependent visualization kept rendering the wrong
feature's data with no indication anything was stale -- the only path here that
propagates a wrong value off the map.
The same expression had a second defect: it tested truthiness, so a real 0, ""
or false was indistinguishable from an absent field and dropped. A gage reading
of zero left the previous gage's number on screen. Presence is now tested
instead, and the field-then-alias fallback no longer discards a falsy field
value either.
The reprojection sweep runs on the other view-replacement path. It had one call
site, on the raster auto-fit; the map-extent path replaces the view too, and the
auto-fit adopts a projection without updating the state that view is rebuilt
from -- so a later extent change reverted the projection underneath features
that had already been moved once, leaving them drawn far off screen while still
reporting the right feature count. Verified by reverting the fix and confirming
the test fails.
One existing test encoded the old variable-input behavior: it swiped to a
feature whose value was the "Null" sentinel and asserted the variable kept the
previous feature's value. That is the defect, so the test was updated rather
than the fix weakened. Treating that sentinel as absent is a slight broadening
of the requirement, which says "absent" -- the harm is identical however absence
is spelled.
Co-Authored-By: Claude Opus 5 (1M context)
---
reactapp/__tests__/components/map/Map.test.js | 118 ++++++++++++++++++
.../components/map/ModuleLoader.test.js | 63 ++++++++++
.../components/visualizations/Map.test.js | 82 +++++++++++-
reactapp/components/map/Map.js | 17 +++
reactapp/components/map/ModuleLoader.js | 9 ++
reactapp/components/visualizations/Map.js | 25 +++-
6 files changed, 306 insertions(+), 8 deletions(-)
diff --git a/reactapp/__tests__/components/map/Map.test.js b/reactapp/__tests__/components/map/Map.test.js
index 8dff0255..e56830f7 100644
--- a/reactapp/__tests__/components/map/Map.test.js
+++ b/reactapp/__tests__/components/map/Map.test.js
@@ -2810,3 +2810,121 @@ describe("onMapMoveEnd registration and mount-time prime", () => {
await waitFor(() => expect(onMapMoveEnd).toHaveBeenCalledTimes(1));
});
});
+
+test("a map-extent view replacement moves vector features with the view", async () => {
+ // The reprojection sweep had one call site, on the raster auto-fit path. This
+ // path replaces the view too: the auto-fit adopts a projection without
+ // updating the state this view is rebuilt from, so a later extent change
+ // reverts the projection underneath features that were already moved once --
+ // leaving them holding the outgoing projection's numbers, drawn far off screen
+ // while still reporting the right feature count.
+ let capturedRef;
+ const RefCapture = ({ mapProps }) => {
+ const ref = useRef();
+ capturedRef = ref;
+ return (
+
+
+
{useMapContext()?.mapReady ? "Map Ready" : "Map Not Ready"}
+
+ );
+ };
+ RefCapture.propTypes = { mapProps: PropTypes.object };
+
+ const layers = [
+ {
+ type: "WebGLTile",
+ props: {
+ source: {
+ type: "GeoTIFF",
+ props: { url: "https://example.com/t.tif" },
+ },
+ name: "Auto-fit Raster",
+ zIndex: 0,
+ },
+ },
+ {
+ type: "VectorLayer",
+ props: {
+ name: "Vector Alongside",
+ zIndex: 1,
+ source: {
+ type: "GeoJSON",
+ props: {},
+ geojson: {
+ type: "FeatureCollection",
+ crs: { type: "name", properties: { name: "EPSG:4326" } },
+ features: [
+ {
+ type: "Feature",
+ properties: {},
+ geometry: { type: "Point", coordinates: [-90.54, 14.48] },
+ },
+ ],
+ },
+ },
+ },
+ },
+ ];
+
+ const { rerender } = render(
+
+
+
+
+ ,
+ );
+
+ expect(await screen.findByText("Map Ready")).toBeInTheDocument();
+
+ const findVector = () =>
+ capturedRef.current
+ ?.getLayers()
+ .getArray()
+ .find((l) => l.get("name") === "Vector Alongside");
+
+ // The auto-fit adopts the raster's EPSG:4326, so the features now hold degrees.
+ await waitFor(() => {
+ expect(capturedRef.current.getView().getProjection().getCode()).toBe(
+ "EPSG:4326",
+ );
+ });
+ await waitFor(() => {
+ const [x] = findVector()
+ .getSource()
+ .getFeatures()[0]
+ .getGeometry()
+ .getCoordinates();
+ expect(Math.abs(x - -90.54)).toBeLessThan(0.01);
+ });
+
+ // Now an extent change rebuilds the view from component state, which the
+ // auto-fit never updated -- so the view goes back to Web Mercator.
+ rerender(
+
+
+
+
+ ,
+ );
+
+ await waitFor(() => {
+ expect(capturedRef.current.getView().getProjection().getCode()).toBe(
+ "EPSG:3857",
+ );
+ });
+
+ // The features must have come with it: metres now, not the degrees they held.
+ await waitFor(() => {
+ const [x] = findVector()
+ .getSource()
+ .getFeatures()[0]
+ .getGeometry()
+ .getCoordinates();
+ expect(Math.abs(x)).toBeGreaterThan(1e6);
+ });
+});
diff --git a/reactapp/__tests__/components/map/ModuleLoader.test.js b/reactapp/__tests__/components/map/ModuleLoader.test.js
index 5ecbd821..538dda6f 100644
--- a/reactapp/__tests__/components/map/ModuleLoader.test.js
+++ b/reactapp/__tests__/components/map/ModuleLoader.test.js
@@ -2581,3 +2581,66 @@ describe("applyAutoRamp", () => {
});
});
});
+
+describe("matchesCondition — a field the feature does not carry", () => {
+ // Left unguarded, the negated operators invert into a match: `!=` becomes
+ // `undefined !== x` and `notIn` becomes "not in the list", both true. One
+ // saved rule then repaints every feature of a layer whose .dbf is missing or
+ // whose schema drifted upstream -- and the layer still renders, so nothing
+ // fails and nobody is told.
+ it.each([
+ ["=", "x"],
+ ["!=", "x"],
+ ["<", 5],
+ ["<=", 5],
+ [">", 5],
+ [">=", 5],
+ ["in", "a,b,c"],
+ ["notIn", "a,b,c"],
+ ])("does not match %s", (operator, conditionValue) => {
+ expect(matchesCondition(undefined, operator, conditionValue)).toBe(false);
+ expect(matchesCondition(null, operator, conditionValue)).toBe(false);
+ });
+
+ it("still answers the presence checks, which are about absence itself", () => {
+ // These deliberately run before the guard: asking whether an absent field is
+ // null has a real answer, and a rule styling "no data" depends on it.
+ expect(matchesCondition(undefined, "isNull", null)).toBe(true);
+ expect(matchesCondition(null, "isNull", null)).toBe(true);
+ expect(matchesCondition(undefined, "isNotNull", null)).toBe(false);
+ });
+
+ it("leaves an empty string as a present value", () => {
+ // "" is something the feature carries, so a comparison against it is
+ // meaningful rather than unanswerable.
+ expect(matchesCondition("", "isNull", null)).toBe(true);
+ expect(matchesCondition("", "!=", "x")).toBe(true);
+ expect(matchesCondition("", "=", "")).toBe(true);
+ });
+
+ it("leaves present-value comparisons untouched", () => {
+ // Regression cover: this function styles every vector layer in the app, so
+ // the guard must change nothing for a field that is actually there.
+ expect(matchesCondition("x", "=", "x")).toBe(true);
+ expect(matchesCondition("x", "!=", "y")).toBe(true);
+ expect(matchesCondition("x", "!=", "x")).toBe(false);
+ expect(matchesCondition(3, "<", 5)).toBe(true);
+ expect(matchesCondition(3, ">", 5)).toBe(false);
+ expect(matchesCondition(0, "=", 0)).toBe(true);
+ expect(matchesCondition(0, "!=", 1)).toBe(true);
+ expect(matchesCondition("b", "in", "a,b,c")).toBe(true);
+ expect(matchesCondition("d", "in", "a,b,c")).toBe(false);
+ expect(matchesCondition("d", "notIn", "a,b,c")).toBe(true);
+ expect(matchesCondition("b", "notIn", "a,b,c")).toBe(false);
+ });
+
+ it("does not repaint a whole layer through a negated rule", () => {
+ // The observable consequence, stated as a scenario: a layer whose features
+ // lack POP2020 and a saved rule of `POP2020 != 0`.
+ const features = [{}, {}, {}].map(() => ({ POP2020: undefined }));
+ const matched = features.filter((f) =>
+ matchesCondition(f.POP2020, "!=", 0),
+ );
+ expect(matched).toHaveLength(0);
+ });
+});
diff --git a/reactapp/__tests__/components/visualizations/Map.test.js b/reactapp/__tests__/components/visualizations/Map.test.js
index 8ac9803d..41ffb183 100644
--- a/reactapp/__tests__/components/visualizations/Map.test.js
+++ b/reactapp/__tests__/components/visualizations/Map.test.js
@@ -2296,25 +2296,103 @@ test("Map click attribute variables update text variable input then swipe and up
fireEvent.click(nextSwiper);
+ // Third feature's field1 is "Null". The bound variable is now cleared rather
+ // than left showing the previous feature's value: leaving it is the one path
+ // here that propagates a wrong value off the map, because every dependent
+ // visualization keeps rendering the previously selected feature's data with no
+ // indication anything is stale.
await waitFor(async () => {
expect(await screen.findByTestId("input-variables")).toHaveTextContent(
JSON.stringify({
- "Test Variable": "another value",
+ "Test Variable": "",
}),
);
});
fireEvent.click(nextSwiper);
+ // Fourth feature belongs to a layer with no attribute-variable binding, so
+ // nothing is written and the cleared value stands. Unchanged behavior.
await waitFor(async () => {
expect(await screen.findByTestId("input-variables")).toHaveTextContent(
JSON.stringify({
- "Test Variable": "another value",
+ "Test Variable": "",
}),
);
});
});
+test("Map click carries a zero attribute value into the variable input", async () => {
+ // The old check was a truthiness test, so a real 0 -- a gage reading of zero,
+ // a count of zero -- was indistinguishable from an absent field and dropped,
+ // leaving the previously clicked feature's value on screen.
+ mockedQueryLayerFeatures.mockResolvedValue([
+ {
+ attributes: { field1: 0 },
+ geometry: { x: 10, y: 10 },
+ layerName: "Some Layer",
+ },
+ ]);
+
+ // The real setPosition drives OpenLayers' auto-pan, which crashes in jsdom.
+ jest.spyOn(Overlay.prototype, "setPosition").mockImplementation(() => {});
+ const handleChange = jest.fn();
+ const dashboard = JSON.parse(JSON.stringify(userDashboard));
+ dashboard.tabs[0].gridItems = [mockedTextVariable];
+ const varInputArgs = JSON.parse(mockedTextVariable.args_string);
+
+ const layers = [
+ {
+ configuration: {
+ type: "ImageLayer",
+ props: {
+ name: "NWC",
+ source: {
+ type: "ESRI Image and Map Service",
+ props: { url: "some_url" },
+ },
+ },
+ },
+ attributeVariables: { "Some Layer": { field1: "Test Variable" } },
+ },
+ ];
+
+ render(
+ createLoadedComponent({
+ children: (
+
+
+
+
+ ),
+ options: { dashboards: { dashboards: [dashboard] } },
+ }),
+ );
+
+ expect(await screen.findByText("Map Ready")).toBeInTheDocument();
+
+ await waitFor(async () => {
+ expect(await screen.findByTestId("input-variables")).toHaveTextContent(
+ JSON.stringify({ "Test Variable": 0 }),
+ );
+ });
+});
+
test("Map hover attribute variables update text variable input", async () => {
// Hover-opened popups should drive variable inputs the same way click
// does — enables hover-driven dashboards where other widgets follow
diff --git a/reactapp/components/map/Map.js b/reactapp/components/map/Map.js
index 3605f81e..58438174 100644
--- a/reactapp/components/map/Map.js
+++ b/reactapp/components/map/Map.js
@@ -332,7 +332,24 @@ const MapComponent = ({
setZoom(visualizationRef.current.getView().getZoom().toFixed(2));
});
+ // Move already-mounted vector features with the view, exactly as the raster
+ // auto-fit path does. Features are parsed into the view projection when they
+ // are added, so replacing the view leaves them holding the outgoing
+ // projection's numbers -- drawn far off screen while still reporting the
+ // right feature count. This path replaces the view too, and until now had no
+ // sweep: a raster auto-fit adopts a projection without updating the state
+ // this view is rebuilt from, so a later extent change reverts the projection
+ // underneath the features.
+ const outgoingCode = visualizationRef.current
+ .getView()
+ .getProjection()
+ .getCode();
visualizationRef.current.setView(mapViewConfig);
+ reprojectVectorFeatures(
+ visualizationRef.current,
+ outgoingCode,
+ mapViewConfig.getProjection().getCode(),
+ );
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [mapExtent]);
diff --git a/reactapp/components/map/ModuleLoader.js b/reactapp/components/map/ModuleLoader.js
index 8fac355c..70ec0a13 100644
--- a/reactapp/components/map/ModuleLoader.js
+++ b/reactapp/components/map/ModuleLoader.js
@@ -803,6 +803,15 @@ export function matchesCondition(featureValue, type, conditionValue) {
return a !== null && a !== undefined && a !== "";
}
+ // A field the feature does not carry cannot satisfy a comparison. Without this
+ // the negated operators invert into a match: `!=` becomes `undefined !== x`,
+ // and `notIn` becomes "not in the list", both true -- so one saved rule
+ // repaints every feature of a layer whose .dbf is missing or whose schema
+ // drifted upstream. The layer still renders, so nothing fails and nobody is
+ // told. The presence checks above deliberately run first: asking whether an
+ // absent field is null is a question with a real answer.
+ if (a === null || a === undefined) return false;
+
const coerce = (v) => (typeof v === "string" && !isNaN(v) ? Number(v) : v);
const av = coerce(a);
diff --git a/reactapp/components/visualizations/Map.js b/reactapp/components/visualizations/Map.js
index 07e1b60f..c027b151 100644
--- a/reactapp/components/visualizations/Map.js
+++ b/reactapp/components/visualizations/Map.js
@@ -637,13 +637,26 @@ const MapVisualization = ({
const variableInputName =
mapAttributeVariables[layerName][layerAttributeOrAlias];
+ // Prefer the field, fall back to the alias only when the field is
+ // genuinely absent. A `||` here would also discard a real 0, "" or
+ // false.
+ const fromField = selectedFeature.attributes[layerAttribute];
const featureValue =
- selectedFeature.attributes[layerAttribute] ||
- selectedFeature.attributes[layerAttributeAlias];
-
- if (featureValue && featureValue !== "Null") {
- updatedVariableInputs[variableInputName] = featureValue;
- }
+ fromField === undefined || fromField === null
+ ? selectedFeature.attributes[layerAttributeAlias]
+ : fromField;
+
+ // Absent means cleared, not left alone. Skipping the write leaves the
+ // variable holding the previously clicked feature's value, so every
+ // dependent visualization keeps showing the wrong feature's data with no
+ // indication anything is stale -- the only path here that propagates a
+ // wrong value off the map. Presence is tested rather than truthiness, so
+ // a real 0, "" or false is carried through instead of being dropped.
+ const isAbsent =
+ featureValue === undefined ||
+ featureValue === null ||
+ featureValue === "Null";
+ updatedVariableInputs[variableInputName] = isAbsent ? "" : featureValue;
}
if (Object.keys(updatedVariableInputs).length > 0) {
setVariableInputValues((previousVariableInputValues) => ({
From b10cfa048f8a1c99cee6ed88f47c7552529babf9 Mon Sep 17 00:00:00 2001
From: Corey Krewson
Date: Tue, 25 Aug 2026 12:20:01 -0700
Subject: [PATCH 11/18] docs(map): document the Shapefile source type
Adds a Shapefile section to the source tab alongside the other twelve, and
updates the type lists that enumerate which sources support click tolerance,
snapping and custom styling.
Covers what an author has to decide: the two accepted URL forms and that one
field takes either, that the coordinate system comes from the file's own .prj
with the projection property as a fallback accepting a definition as well as a
code, and that a missing .dbf still draws geometry but offers no fields.
Three things are documented because they are surprising rather than because they
are configurable. Reading the fields is an explicit action, since it is a large
download and one read serves both tabs. Saved rules naming a field the source no
longer has are listed rather than silently matching nothing. And the size limit
applies to the decompressed components, so the number an author sees is not the
size of the file they linked.
The cross-origin note names the constraint concretely. Most agency portals send
permissive headers, but Census TIGER -- among the most-used boundary sources in
the country -- cannot be read from a browser at all, so the note says which way
the common cases fall and what to do instead rather than describing CORS in the
abstract.
Verified against a docs build: no new errors or warnings, and the same count as
before the change.
Co-Authored-By: Claude Opus 5 (1M context)
---
docs/source/maps/layer_tab.rst | 6 +++---
docs/source/maps/source_tab.rst | 32 ++++++++++++++++++++++++++++++++
docs/source/maps/style_tab.rst | 2 +-
3 files changed, 36 insertions(+), 4 deletions(-)
diff --git a/docs/source/maps/layer_tab.rst b/docs/source/maps/layer_tab.rst
index 9f8ba77d..fc3c8004 100644
--- a/docs/source/maps/layer_tab.rst
+++ b/docs/source/maps/layer_tab.rst
@@ -21,8 +21,8 @@ The layer tab is used to configure the overall layer, including its name and pro
- **minZoom:** Minimum zoom level (exclusive) for layer visibility.
- **maxZoom:** Maximum zoom level (inclusive) for layer visibility.
- **minZoomQuery:** Minimum zoom level (inclusive) at which the layer can be queried. If the map is clicked beyond this zoom, it will zoom in to minZoomQuery.
- - **clickTolerance:** Pixel tolerance for clicking (and hovering) features. For ``ESRI Image and Map Service`` layers this is the server identify tolerance (default ``10``). For ``GeoJSON`` and ``ESRI Feature Service`` layers it widens the on-screen hit area for clicks and hover popups (default ``0`` — exact hit). When **snapToFeatures** is enabled, this value also sets the snap radius (default ``15``). See `Feature Snapping and Click Tolerance`_ below.
- - **snapToFeatures:** Snap the cursor to the nearest feature of this layer while hovering, and select that feature on click. Supported for ``ESRI Image and Map Service``, ``GeoJSON``, and ``ESRI Feature Service`` sources. See `Feature Snapping and Click Tolerance`_ below.
+ - **clickTolerance:** Pixel tolerance for clicking (and hovering) features. For ``ESRI Image and Map Service`` layers this is the server identify tolerance (default ``10``). For ``GeoJSON``, ``ESRI Feature Service`` and ``Shapefile`` layers it widens the on-screen hit area for clicks and hover popups (default ``0`` — exact hit). When **snapToFeatures** is enabled, this value also sets the snap radius (default ``15``). See `Feature Snapping and Click Tolerance`_ below.
+ - **snapToFeatures:** Snap the cursor to the nearest feature of this layer while hovering, and select that feature on click. Supported for ``ESRI Image and Map Service``, ``GeoJSON``, ``ESRI Feature Service`` and ``Shapefile`` sources. See `Feature Snapping and Click Tolerance`_ below.
- **snapSublayer:** For ``ESRI Image and Map Service`` snap layers only — the MapServer sublayer used to load snapping features. Defaults to the first id in the source's ``LAYERS`` ``show:N`` parameter, or ``0``. Set it explicitly when the sublayer you want to snap to differs from that default.
@@ -47,7 +47,7 @@ Clicking exactly on a thin line or a small point is hard — especially on dense
How snapping behaves:
-- **Where the features come from.** ``GeoJSON`` and ``ESRI Feature Service`` layers snap against the features already rendered in the browser — what you see is exactly what you can snap to, with no extra network requests. ``ESRI Image and Map Service`` layers load their features for the current view from the service's ``/query`` endpoint after each pan or zoom (respecting any ``LAYERDEFS`` filter, and the sublayer chosen by **snapSublayer**).
+- **Where the features come from.** ``GeoJSON``, ``ESRI Feature Service`` and ``Shapefile`` layers snap against the features already rendered in the browser — what you see is exactly what you can snap to, with no extra network requests. ``ESRI Image and Map Service`` layers load their features for the current view from the service's ``/query`` endpoint after each pan or zoom (respecting any ``LAYERDEFS`` filter, and the sublayer chosen by **snapSublayer**).
- **Snapping follows visibility.** A layer only snaps while it is actually drawn: turning the layer off in the layer control, or moving outside its **minZoom** / **maxZoom** / **minResolution** / **maxResolution** bounds, disables snapping immediately.
- **minZoomQuery applies.** Like popup queries, snapping is inactive below the layer's **minZoomQuery** zoom level.
- **Radius.** The hover snap radius is **clickTolerance** when set, otherwise ``15`` pixels. The confluence gather radius is at least ``35`` pixels and never narrower than the snap radius.
diff --git a/docs/source/maps/source_tab.rst b/docs/source/maps/source_tab.rst
index 78ec60e5..5bfca900 100644
--- a/docs/source/maps/source_tab.rst
+++ b/docs/source/maps/source_tab.rst
@@ -96,6 +96,38 @@ The GeoJSON source is different from the other options. It provides a text area
------------------------------------------------------------------------------------------------------------------------
++++++++++
+Shapefile
++++++++++
+
+
+The Shapefile source draws an ESRI Shapefile that is already published on the web. The browser fetches and reads it directly — nothing is uploaded to TethysDash, and the saved layer keeps only the URL, so the file stays wherever it already lives and stays current when it is replaced there.
+
+Two forms are accepted, and the same **url** field takes either:
+
+- A **zipped shapefile**, whose path ends in ``.zip``.
+- The **.shp component** of an unzipped set. The sibling ``.dbf``, ``.prj`` and ``.shx`` files are requested from the same path automatically, so only the ``.shp`` URL is entered. Any query string on the URL is preserved on each request, which keeps signed links working.
+
+ - **url:** *(required)* URL of a zipped shapefile, or of its ``.shp`` component. Must be ``http`` or ``https``.
+ - **projection:** *(optional)* Used only when the shapefile carries no ``.prj``. Accepts a code such as ``EPSG:5070``, or a full WKT or proj4 definition for a coordinate system the map does not already know.
+ - **attributions:** *(optional)* Attribution text for the layer.
+
+The coordinate system comes from the shapefile's own ``.prj`` and does not need to be entered. If the file has no ``.prj``, the **projection** property is used instead; if neither is present the layer reports that its coordinates cannot be placed rather than guessing at them.
+
+Attribute values come from the ``.dbf``. If that component is missing, the geometry still draws but the layer offers no fields for style rules or popups.
+
+Styling, popups, attribute variables and snapping all behave as they do for a :ref:`GeoJSON ` layer — see the :ref:`style_tab` and :ref:`attributes_and_popups_tab`.
+
+**Reading the fields.** The Style and Attributes tabs need the ``.dbf`` field names, which means reading the source. Because that can be a large download, it happens when you ask for it: use **Read shapefile fields** on this tab. One read serves both tabs. If the saved style rules, popup settings or attribute variables name a field the source no longer has — after the file is republished with a renamed column, for instance — the field is listed so the affected rules can be corrected. Those rules will not match anything until then, and the layer will still draw.
+
+**Size limit.** A shapefile is read in one piece, so the components are limited to 25 MB once decompressed. A source above the limit is refused before it is expanded, and the message states both the observed and the permitted size. Clip or simplify the data, or serve a reduced copy.
+
+.. note::
+
+ The browser must be allowed to fetch the file, which means the host has to send permissive `CORS `_ headers. Most agency open-data portals do — ArcGIS Hub and AWS-hosted government buckets among them — but some widely used sources do not. Census TIGER files, for example, cannot be read from a browser at all. When a fetch is refused, the layer says so and suggests converting the shapefile to GeoJSON and using the :ref:`GeoJSON ` source instead, which stores the data with the dashboard rather than fetching it.
+
+------------------------------------------------------------------------------------------------------------------------
+
+++++++++++
Vector Tile
+++++++++++
diff --git a/docs/source/maps/style_tab.rst b/docs/source/maps/style_tab.rst
index 32e36c27..f6aa6586 100644
--- a/docs/source/maps/style_tab.rst
+++ b/docs/source/maps/style_tab.rst
@@ -6,7 +6,7 @@ Style Tab
-The style tab lets you apply custom styles to map layers. Custom styling is available for GeoJSON, ESRI Feature Service, and PMTiles Vector layers. Two types of styling are supported:
+The style tab lets you apply custom styles to map layers. Custom styling is available for GeoJSON, ESRI Feature Service, PMTiles Vector, and Shapefile layers. Two types of styling are supported:
**MapLibre Styling**: Follows the `MapLibre Style Spec `_ and uses the `ol-mapbox-style applyStyle `_ function. Refer to these resources to ensure your layers render correctly.
From cf16d2cab718f0f6de5ce84f34ee0345900cf9b4 Mon Sep 17 00:00:00 2001
From: Corey Krewson
Date: Tue, 25 Aug 2026 14:00:24 -0700
Subject: [PATCH 12/18] fix(map): accept portal download endpoints as shapefile
sources
A URL whose path carries no recognised extension is now read as an archive and
the bytes decide, rather than being rejected on path shape. That shape is what
most portals actually hand out: ArcGIS Hub serves shapefiles from a path ending
in "data", with the format in the query string. Rejecting it turned away the
exact host class the pre-implementation survey identified as the one that
matters -- so the requirement to reject any path not ending in .zip or .shp was
wrong, and following it verbatim was my mistake.
A path naming a different format outright -- .geojson, .kml, .csv, .tif and
similar -- is still refused before a request goes out, since that is a mistake
worth catching early rather than a download endpoint.
URL problems are also no longer classified as fetch failures. They reported the
fetch stage, so an unsupported path produced the convert-to-GeoJSON remedy and a
retry button: both misleading, because the host was never contacted and
re-running an unsupported URL fails identically forever. They now carry their own
stage and kind, which withholds both.
The not-an-archive message covers both ways it now happens, since an
unrecognised path gives no hint which: a host returning an error page with a
success status, or a URL pointing at an unzipped shapefile that should have been
given as its .shp.
Found by testing against a real ArcGIS Hub URL. I had verified that URL's CORS
headers and content type but never run it through the validator.
Co-Authored-By: Claude Opus 5 (1M context)
---
docs/source/maps/source_tab.rst | 2 +
.../components/map/layerStatus.test.js | 9 ++++
.../components/map/shapefile/acquire.test.js | 30 ++++++++++-
.../components/map/shapefile/siblings.test.js | 43 ++++++++++++----
reactapp/components/map/layerStatus.js | 5 ++
reactapp/components/map/shapefile/siblings.js | 50 +++++++++++++++----
reactapp/components/map/shapefile/unzip.js | 2 +-
7 files changed, 120 insertions(+), 21 deletions(-)
diff --git a/docs/source/maps/source_tab.rst b/docs/source/maps/source_tab.rst
index 5bfca900..4df4c351 100644
--- a/docs/source/maps/source_tab.rst
+++ b/docs/source/maps/source_tab.rst
@@ -108,6 +108,8 @@ Two forms are accepted, and the same **url** field takes either:
- A **zipped shapefile**, whose path ends in ``.zip``.
- The **.shp component** of an unzipped set. The sibling ``.dbf``, ``.prj`` and ``.shx`` files are requested from the same path automatically, so only the ``.shp`` URL is entered. Any query string on the URL is preserved on each request, which keeps signed links working.
+A portal **download endpoint** counts as the first form even when its path carries no ``.zip`` — ArcGIS Hub and similar services serve shapefiles from a URL ending in something like ``/downloads/data?format=shp``. Paste it as-is.
+
- **url:** *(required)* URL of a zipped shapefile, or of its ``.shp`` component. Must be ``http`` or ``https``.
- **projection:** *(optional)* Used only when the shapefile carries no ``.prj``. Accepts a code such as ``EPSG:5070``, or a full WKT or proj4 definition for a coordinate system the map does not already know.
- **attributions:** *(optional)* Attribution text for the layer.
diff --git a/reactapp/__tests__/components/map/layerStatus.test.js b/reactapp/__tests__/components/map/layerStatus.test.js
index 3ab324b4..407a0581 100644
--- a/reactapp/__tests__/components/map/layerStatus.test.js
+++ b/reactapp/__tests__/components/map/layerStatus.test.js
@@ -66,6 +66,15 @@ describe("errorKindFor", () => {
},
);
+ it("gives a url-shape problem its own kind so no host remedy is offered", () => {
+ // A rejected url is not a reachability failure. Classifying it as one made
+ // the editor suggest converting the file because the host could not be
+ // reached, and offer a retry that would fail identically forever.
+ const kind = errorKindFor({ stage: "input", reason: "unsupported_path" });
+ expect(kind).toBe(ERROR_KIND.INPUT);
+ expect(isRetryable(kind)).toBe(false);
+ });
+
it("defaults to the fetch kind for an unrecognised failure", () => {
expect(errorKindFor(undefined)).toBe(ERROR_KIND.FETCH);
expect(errorKindFor({})).toBe(ERROR_KIND.FETCH);
diff --git a/reactapp/__tests__/components/map/shapefile/acquire.test.js b/reactapp/__tests__/components/map/shapefile/acquire.test.js
index 922c78c4..3b19ae3d 100644
--- a/reactapp/__tests__/components/map/shapefile/acquire.test.js
+++ b/reactapp/__tests__/components/map/shapefile/acquire.test.js
@@ -59,13 +59,41 @@ describe("acquireComponents — validation happens before any request", () => {
expect(fetchMock).not.toHaveBeenCalled();
});
- it("rejects an unsupported path without fetching", async () => {
+ it("rejects a path naming a different format without fetching", async () => {
const result = await acquireComponents(
"https://example.org/basins.geojson",
);
expect(result.error.reason).toBe("unsupported_path");
+ expect(result.error.stage).toBe("input");
expect(fetchMock).not.toHaveBeenCalled();
});
+
+ it("fetches an extensionless download endpoint as an archive", async () => {
+ fetchMock.mockResolvedValue(respond({ body: ARCHIVE }));
+
+ const result = await acquireComponents(
+ "https://hub.arcgis.com/api/v3/datasets/abc_0/downloads/data?format=shp",
+ );
+
+ expect(result.error).toBeUndefined();
+ expect(result.components.shp).toBeTruthy();
+ });
+
+ it("reports an extensionless endpoint that does not return an archive", async () => {
+ fetchMock.mockResolvedValue(
+ respond({
+ contentType: "application/octet-stream",
+ body: bytes("not a zip at all"),
+ }),
+ );
+
+ const result = await acquireComponents("https://example.org/export");
+
+ expect(result.error.reason).toBe("unreadable_archive");
+ // The message has to cover both ways this happens, since the path gave no
+ // hint about which.
+ expect(result.error.detail).toMatch(/\.shp URL|error page/);
+ });
});
describe("acquireComponents — archive form", () => {
diff --git a/reactapp/__tests__/components/map/shapefile/siblings.test.js b/reactapp/__tests__/components/map/shapefile/siblings.test.js
index 3e515bc9..45199922 100644
--- a/reactapp/__tests__/components/map/shapefile/siblings.test.js
+++ b/reactapp/__tests__/components/map/shapefile/siblings.test.js
@@ -27,24 +27,49 @@ describe("validateSourceUrl", () => {
expect(error.detail).toMatch(/https?/);
});
- it("rejects a path ending in neither .zip nor .shp and names both forms", () => {
+ it("rejects a path naming a different format outright", () => {
const { error } = validateSourceUrl(
"https://example.org/data/basins.geojson",
);
expect(error.reason).toBe("unsupported_path");
- expect(error.detail).toContain(".zip");
- expect(error.detail).toContain(".shp");
+ expect(error.detail).toContain("geojson");
});
- it("classifies by the path, not the query string", () => {
- // A download endpoint whose query says "shp" is still not a .shp path, and a
- // .zip path with an unrelated query still is an archive.
- expect(
- validateSourceUrl("https://example.org/download?format=shp").error,
- ).toBeTruthy();
+ it.each([
+ "https://hub.arcgis.com/api/v3/datasets/abc_0/downloads/data?format=shp",
+ "https://opendata.arcgis.com/api/v3/datasets/abc_0/downloads/data?format=shp",
+ "https://example.org/download?format=shp",
+ "https://example.org/export",
+ ])("accepts the extensionless download endpoint %s", (url) => {
+ // This is the shape most portals actually hand out: ArcGIS Hub serves
+ // shapefiles from a path ending in "data", with the format in the query
+ // string. Rejecting on path shape alone turned away the host class the
+ // pre-implementation survey found matters most -- so an unrecognised path is
+ // treated as an archive and the bytes decide.
+ expect(validateSourceUrl(url).form).toBe("archive");
+ });
+
+ it("still classifies a recognised extension from the path, not the query", () => {
expect(
validateSourceUrl("https://example.org/basins.zip?token=abc").form,
).toBe("archive");
+ expect(
+ validateSourceUrl("https://example.org/basins.shp?token=abc").form,
+ ).toBe("components");
+ });
+
+ it("marks every url rejection as an input problem, not a host problem", () => {
+ // These must not suggest converting the file because the host is
+ // unreachable, and must not offer a retry: the host is fine, and re-running
+ // an unsupported url fails identically forever.
+ [
+ "data:application/zip;base64,UEs=",
+ "//example.org/basins.zip",
+ "https://example.org/basins.geojson",
+ "",
+ ].forEach((url) => {
+ expect(validateSourceUrl(url).error.stage).toBe("input");
+ });
});
it("rejects an empty or non-string url", () => {
diff --git a/reactapp/components/map/layerStatus.js b/reactapp/components/map/layerStatus.js
index 8a57eba9..acbe6ed2 100644
--- a/reactapp/components/map/layerStatus.js
+++ b/reactapp/components/map/layerStatus.js
@@ -30,6 +30,10 @@ export const CANCEL_REASON = {
* way.
*/
export const ERROR_KIND = {
+ // Something wrong with the configured URL itself. Distinct from FETCH because
+ // the host is not the problem: suggesting the file be converted because the
+ // host is unreachable would be misleading, and a retry would fail identically.
+ INPUT: "input",
FETCH: "fetch",
PARSE: "parse",
TOO_LARGE: "too_large",
@@ -61,6 +65,7 @@ export function isRetryable(kind) {
* @returns {string} One of ERROR_KIND.
*/
export function errorKindFor(failure) {
+ if (failure?.stage === "input") return ERROR_KIND.INPUT;
if (failure?.reason === "too_large") return ERROR_KIND.TOO_LARGE;
if (
failure?.reason === "missing_projection" ||
diff --git a/reactapp/components/map/shapefile/siblings.js b/reactapp/components/map/shapefile/siblings.js
index 07d2653c..279dcf26 100644
--- a/reactapp/components/map/shapefile/siblings.js
+++ b/reactapp/components/map/shapefile/siblings.js
@@ -5,10 +5,29 @@ export const COMPONENT_EXTENSIONS = ["shp", "dbf", "prj", "shx"];
const ALLOWED_PROTOCOLS = ["http:", "https:"];
-function failure(reason, detail) {
- return { error: { stage: "fetch", reason, detail } };
+// Something wrong with what the author typed, as distinct from something wrong
+// with the host. Kept separate so these never suggest converting the file
+// because the host is unreachable, and never offer a retry -- re-running an
+// unsupported URL fails identically forever.
+function inputFailure(reason, detail) {
+ return { error: { stage: "input", reason, detail } };
}
+// Extensions that clearly name a different format. A path ending in one of these
+// is a mistake worth catching before a request goes out; a path with no
+// extension at all is not, since that is what a download endpoint looks like.
+const WRONG_FORMAT_EXTENSIONS = [
+ "geojson",
+ "json",
+ "kml",
+ "kmz",
+ "csv",
+ "tif",
+ "tiff",
+ "gpkg",
+ "gdb",
+];
+
// The extension of the final path segment, lower-cased, or "" when there is
// none. Read from the path alone: a download endpoint whose query string says
// `format=shp` is not a .shp path, and a .zip path carrying a cache token still
@@ -34,7 +53,7 @@ function pathExtension(url) {
*/
export function validateSourceUrl(rawUrl) {
if (typeof rawUrl !== "string" || rawUrl.trim() === "") {
- return failure("empty", "No shapefile URL was supplied.");
+ return inputFailure("empty", "No shapefile URL was supplied.");
}
const trimmed = rawUrl.trim();
@@ -42,7 +61,7 @@ export function validateSourceUrl(rawUrl) {
// Checked before parsing, because a protocol-relative URL has no protocol to
// report and would otherwise surface as an unhelpful malformed-URL error.
if (trimmed.startsWith("//")) {
- return failure(
+ return inputFailure(
"unsupported_scheme",
"A protocol-relative URL is not accepted. Use an http:// or https:// URL.",
);
@@ -52,11 +71,11 @@ export function validateSourceUrl(rawUrl) {
try {
url = new URL(trimmed);
} catch {
- return failure("malformed_url", `"${trimmed}" is not a valid URL.`);
+ return inputFailure("malformed_url", `"${trimmed}" is not a valid URL.`);
}
if (!ALLOWED_PROTOCOLS.includes(url.protocol)) {
- return failure(
+ return inputFailure(
"unsupported_scheme",
`The scheme "${url.protocol}" is not accepted. Use an http:// or https:// URL.`,
);
@@ -64,12 +83,23 @@ export function validateSourceUrl(rawUrl) {
const extension = pathExtension(url);
if (extension === "zip") return { form: "archive", url: trimmed };
+ // A .shp path is the only case sibling derivation can work from, since it
+ // needs an extension to replace.
if (extension === "shp") return { form: "components", url: trimmed };
- return failure(
- "unsupported_path",
- "The URL path must end in .zip for a zipped shapefile, or .shp for an unzipped one.",
- );
+ if (WRONG_FORMAT_EXTENSIONS.includes(extension)) {
+ return inputFailure(
+ "unsupported_path",
+ `A ".${extension}" file is not a shapefile. Supply a zipped shapefile, or the .shp of an unzipped one.`,
+ );
+ }
+
+ // No extension, or one we do not recognise: treat it as an archive and let the
+ // bytes decide. Portal download endpoints are the common shape here -- ArcGIS
+ // Hub serves shapefiles from paths ending in "data", with the format in the
+ // query string -- and rejecting those on path shape alone would turn away the
+ // host class most authors actually use.
+ return { form: "archive", url: trimmed };
}
/**
diff --git a/reactapp/components/map/shapefile/unzip.js b/reactapp/components/map/shapefile/unzip.js
index a12a2428..ac8006c1 100644
--- a/reactapp/components/map/shapefile/unzip.js
+++ b/reactapp/components/map/shapefile/unzip.js
@@ -78,7 +78,7 @@ export function unzipShapefileComponents(buffer, { maxBytes }) {
stage: "parse",
reason: "unreadable_archive",
detail:
- "The source is not a zip archive. A portal returning an error page with a success status is the usual cause.",
+ "The source is not a zip archive. Either the host returned an error page with a success status, or the URL points at something else -- an unzipped shapefile has to be given as its .shp URL.",
},
};
}
From e3ba89b68547e59fbcf1572879ff6d283950ea13 Mon Sep 17 00:00:00 2001
From: Corey Krewson
Date: Tue, 25 Aug 2026 14:25:33 -0700
Subject: [PATCH 13/18] Fix shapefile attribute text: NUL padding and .dbf
encoding
Two independent defects, both of which produced readable-looking attribute
values that were wrong rather than any error.
The parser strips field padding with String.prototype.trim(), and NUL is not
whitespace in JavaScript. A .dbf that pads its character fields with NUL rather
than spaces -- Natural Earth's do -- yielded values carrying their padding,
which renders as a run of tofu boxes. An all-padding field was a truthy string
of NULs instead of null, so an empty value arrived as content that popups drew
and style rules matched. A NUL-padded numeric field failed `+value` and read as
null with nothing reported.
The parser also defaults to windows-1252 regardless of what the source says it
wrote, so a UTF-8 .dbf turned every non-ASCII name into mojibake. The .cpg
component that names the encoding was never fetched.
Both are handled at the bytes, before the parser sees them: .cpg joins the
component set (so the archive and sibling paths both pick it up), NUL padding in
the record region is rewritten to spaces on a copy, and the encoding is taken
from the .cpg when it names something usable. With no .cpg the record region is
sniffed for UTF-8 -- self-validating, so an invalid sequence is proof it is not
-- and failing that the parser's own default stands, leaving a file with no
encoding information decoding exactly as before.
The header is left alone in both cases: a DBF declares its own length at offset
8, everything before it is binary, and everything after it is text for every
field type this parser reads.
Fixture-backed, with the fixture's own defect asserted so the tests cannot pass
vacuously.
Co-Authored-By: Claude Opus 5 (1M context)
---
docs/source/maps/source_tab.rst | 4 +-
.../components/map/shapefile/acquire.test.js | 2 +
.../map/shapefile/attributes.test.js | 193 ++++++++++++++++++
.../components/map/shapefile/index.test.js | 74 ++++++-
.../components/map/shapefile/siblings.test.js | 1 +
.../utilities/fixtures/shapefile/encoded.cpg | 1 +
.../utilities/fixtures/shapefile/encoded.dbf | Bin 0 -> 204 bytes
.../utilities/fixtures/shapefile/encoded.prj | 1 +
.../utilities/fixtures/shapefile/encoded.shp | Bin 0 -> 128 bytes
.../utilities/fixtures/shapefile/encoded.shx | Bin 0 -> 108 bytes
.../utilities/fixtures/shapefile/generate.py | 66 ++++++
reactapp/components/map/shapefile/acquire.js | 9 +-
.../components/map/shapefile/attributes.js | 181 ++++++++++++++++
reactapp/components/map/shapefile/index.js | 10 +-
reactapp/components/map/shapefile/siblings.js | 8 +-
15 files changed, 540 insertions(+), 10 deletions(-)
create mode 100644 reactapp/__tests__/components/map/shapefile/attributes.test.js
create mode 100644 reactapp/__tests__/utilities/fixtures/shapefile/encoded.cpg
create mode 100644 reactapp/__tests__/utilities/fixtures/shapefile/encoded.dbf
create mode 100644 reactapp/__tests__/utilities/fixtures/shapefile/encoded.prj
create mode 100644 reactapp/__tests__/utilities/fixtures/shapefile/encoded.shp
create mode 100644 reactapp/__tests__/utilities/fixtures/shapefile/encoded.shx
create mode 100644 reactapp/components/map/shapefile/attributes.js
diff --git a/docs/source/maps/source_tab.rst b/docs/source/maps/source_tab.rst
index 4df4c351..212c4e32 100644
--- a/docs/source/maps/source_tab.rst
+++ b/docs/source/maps/source_tab.rst
@@ -106,7 +106,7 @@ The Shapefile source draws an ESRI Shapefile that is already published on the we
Two forms are accepted, and the same **url** field takes either:
- A **zipped shapefile**, whose path ends in ``.zip``.
-- The **.shp component** of an unzipped set. The sibling ``.dbf``, ``.prj`` and ``.shx`` files are requested from the same path automatically, so only the ``.shp`` URL is entered. Any query string on the URL is preserved on each request, which keeps signed links working.
+- The **.shp component** of an unzipped set. The sibling ``.dbf``, ``.prj``, ``.shx`` and ``.cpg`` files are requested from the same path automatically, so only the ``.shp`` URL is entered. Any query string on the URL is preserved on each request, which keeps signed links working.
A portal **download endpoint** counts as the first form even when its path carries no ``.zip`` — ArcGIS Hub and similar services serve shapefiles from a URL ending in something like ``/downloads/data?format=shp``. Paste it as-is.
@@ -118,6 +118,8 @@ The coordinate system comes from the shapefile's own ``.prj`` and does not need
Attribute values come from the ``.dbf``. If that component is missing, the geometry still draws but the layer offers no fields for style rules or popups.
+**Attribute text.** The character encoding comes from the ``.cpg`` component when the shapefile has one. When it does not, UTF-8 is detected from the attribute bytes themselves, and anything that is not valid UTF-8 is read as Windows-1252. Values whose padding is unusual — some publishers pad with null bytes rather than spaces — are trimmed either way, so a field that is empty in the source reads as empty rather than as padding characters.
+
Styling, popups, attribute variables and snapping all behave as they do for a :ref:`GeoJSON ` layer — see the :ref:`style_tab` and :ref:`attributes_and_popups_tab`.
**Reading the fields.** The Style and Attributes tabs need the ``.dbf`` field names, which means reading the source. Because that can be a large download, it happens when you ask for it: use **Read shapefile fields** on this tab. One read serves both tabs. If the saved style rules, popup settings or attribute variables name a field the source no longer has — after the file is republished with a renamed column, for instance — the field is listed so the affected rules can be corrected. Those rules will not match anything until then, and the layer will still draw.
diff --git a/reactapp/__tests__/components/map/shapefile/acquire.test.js b/reactapp/__tests__/components/map/shapefile/acquire.test.js
index 3b19ae3d..3fbd8037 100644
--- a/reactapp/__tests__/components/map/shapefile/acquire.test.js
+++ b/reactapp/__tests__/components/map/shapefile/acquire.test.js
@@ -185,6 +185,7 @@ describe("acquireComponents — sibling form", () => {
expect(result.error).toBeUndefined();
expect(Object.keys(result.components).sort()).toEqual([
+ "cpg",
"dbf",
"prj",
"shp",
@@ -196,6 +197,7 @@ describe("acquireComponents — sibling form", () => {
"https://example.org/basins.dbf",
"https://example.org/basins.prj",
"https://example.org/basins.shx",
+ "https://example.org/basins.cpg",
]);
});
diff --git a/reactapp/__tests__/components/map/shapefile/attributes.test.js b/reactapp/__tests__/components/map/shapefile/attributes.test.js
new file mode 100644
index 00000000..d6476563
--- /dev/null
+++ b/reactapp/__tests__/components/map/shapefile/attributes.test.js
@@ -0,0 +1,193 @@
+import {
+ prepareAttributes,
+ encodingFromCodePage,
+ looksLikeUtf8,
+} from "components/map/shapefile/attributes";
+import { bytes } from "../../../utilities/bytes";
+
+const NUL = 0x00;
+const SPACE = 0x20;
+
+// A .dbf-shaped buffer: a header of the declared length followed by record
+// bytes. Only the length at offset 8 matters to the code under test, so the rest
+// of the header is filled with a byte that would be rewritten if the record
+// boundary were ignored.
+function dbfLike(recordBytes, { headerLength = 33 } = {}) {
+ const buffer = new Uint8Array(headerLength + recordBytes.length);
+ buffer.fill(NUL);
+ buffer[8] = headerLength & 0xff;
+ buffer[9] = (headerLength >> 8) & 0xff;
+ buffer.set(recordBytes, headerLength);
+ return buffer;
+}
+
+function ascii(text) {
+ return Array.from(text).map((character) => character.charCodeAt(0));
+}
+
+describe("encodingFromCodePage", () => {
+ it("reads the label a .cpg declares", () => {
+ expect(encodingFromCodePage(bytes("UTF-8"))).toBe("utf-8");
+ });
+
+ it("accepts a numeric code page", () => {
+ expect(encodingFromCodePage(bytes("65001"))).toBe("utf-8");
+ expect(encodingFromCodePage(bytes("1252"))).toBe("windows-1252");
+ });
+
+ it("ignores the trailing newline a .cpg is usually written with", () => {
+ // Neither trim() nor TextDecoder would reject the result -- it would just be
+ // an unusable label, and the encoding would silently fall back.
+ expect(
+ encodingFromCodePage(new Uint8Array([...ascii("UTF-8"), 0x0a])),
+ ).toBe("utf-8");
+ });
+
+ it("ignores trailing NUL padding in a .cpg", () => {
+ expect(encodingFromCodePage(new Uint8Array([...ascii("UTF-8"), NUL]))).toBe(
+ "utf-8",
+ );
+ });
+
+ it("maps an ESRI spelling onto the label TextDecoder knows", () => {
+ expect(encodingFromCodePage(bytes("ANSI"))).toBe("windows-1252");
+ expect(encodingFromCodePage(bytes("LATIN1"))).toBe("iso-8859-1");
+ });
+
+ it("passes through a valid label it does not enumerate", () => {
+ expect(encodingFromCodePage(bytes("ISO-8859-7"))).toBe("iso-8859-7");
+ });
+
+ it("returns null for a label TextDecoder cannot construct", () => {
+ // Reported as "no declared encoding" rather than handed to the parser, which
+ // builds its decoder inside the read and would blame the geometry.
+ expect(encodingFromCodePage(bytes("NOT-AN-ENCODING"))).toBeNull();
+ });
+
+ it("returns null for an absent or empty .cpg", () => {
+ expect(encodingFromCodePage(undefined)).toBeNull();
+ expect(encodingFromCodePage(new Uint8Array())).toBeNull();
+ });
+});
+
+describe("looksLikeUtf8", () => {
+ it("does not treat pure ASCII as evidence either way", () => {
+ // ASCII decodes identically under both candidates, so calling it UTF-8 would
+ // be an unfounded claim that happens to be harmless -- and the next reader
+ // would trust it for a file where it is not.
+ expect(looksLikeUtf8(new Uint8Array(ascii("Minnesota")), 0)).toBe(false);
+ });
+
+ it("recognises multi-byte UTF-8", () => {
+ const utf8 = new Uint8Array([0xe6, 0x98, 0x8e, 0xe5, 0xb0, 0xbc]);
+ expect(looksLikeUtf8(utf8, 0)).toBe(true);
+ });
+
+ it("rejects bytes that are not valid UTF-8", () => {
+ // 0xF1 in isolation is a windows-1252 "ñ" and an invalid UTF-8 lead byte,
+ // which is what makes UTF-8's self-validation usable as a test.
+ expect(looksLikeUtf8(new Uint8Array([0x4d, 0x69, 0xf1, 0x6f]), 0)).toBe(
+ false,
+ );
+ });
+
+ it("looks only past the offset it is given", () => {
+ // The header is binary, so bytes before the record region are not text and
+ // would fail the check for reasons that say nothing about the attributes.
+ const buffer = new Uint8Array([0xff, 0xfe, 0xe6, 0x98, 0x8e]);
+ expect(looksLikeUtf8(buffer, 0)).toBe(false);
+ expect(looksLikeUtf8(buffer, 2)).toBe(true);
+ });
+});
+
+describe("prepareAttributes", () => {
+ it("rewrites NUL padding in the record region as spaces", () => {
+ // The parser strips padding with trim(), and NUL is not whitespace in
+ // JavaScript, so without this the padding arrives as part of the value.
+ const dbf = dbfLike(new Uint8Array([...ascii("Basin"), NUL, NUL, NUL]));
+ const prepared = prepareAttributes(dbf);
+
+ expect(Array.from(prepared.dbf.slice(33))).toEqual([
+ ...ascii("Basin"),
+ SPACE,
+ SPACE,
+ SPACE,
+ ]);
+ });
+
+ it("leaves the binary header untouched", () => {
+ // Field lengths, decimal counts and flags are binary, and a NUL there is a
+ // value rather than padding.
+ const dbf = dbfLike(new Uint8Array([...ascii("Basin"), NUL]));
+ const prepared = prepareAttributes(dbf);
+
+ const header = Array.from(prepared.dbf.slice(0, 33));
+ expect(header.filter((byte) => byte === SPACE)).toHaveLength(0);
+ expect(header[8]).toBe(33);
+ });
+
+ it("does not mutate the buffer it was given", () => {
+ // These buffers are cached and handed back to callers as the bytes the host
+ // served; quietly differing from that is a trap for anything reading them
+ // later.
+ const dbf = dbfLike(new Uint8Array([...ascii("Basin"), NUL]));
+ const before = Array.from(dbf);
+
+ prepareAttributes(dbf);
+
+ expect(Array.from(dbf)).toEqual(before);
+ });
+
+ it("returns the same buffer when there is no padding to rewrite", () => {
+ const dbf = dbfLike(new Uint8Array(ascii("Basin")));
+ expect(prepareAttributes(dbf).dbf).toBe(dbf);
+ });
+
+ it("prefers the encoding the .cpg declares over what the bytes look like", () => {
+ // The .cpg is the only explicit statement of intent available. These bytes
+ // are valid UTF-8, so the sniffer would say utf-8 -- the declaration wins.
+ const dbf = dbfLike(new Uint8Array([0xe6, 0x98, 0x8e]));
+ expect(prepareAttributes(dbf, bytes("ISO-8859-1")).encoding).toBe(
+ "iso-8859-1",
+ );
+ });
+
+ it("sniffs UTF-8 when no .cpg says otherwise", () => {
+ const dbf = dbfLike(new Uint8Array([0xe6, 0x98, 0x8e]));
+ expect(prepareAttributes(dbf).encoding).toBe("utf-8");
+ });
+
+ it("sniffs UTF-8 when the .cpg declares something unusable", () => {
+ const dbf = dbfLike(new Uint8Array([0xe6, 0x98, 0x8e]));
+ expect(prepareAttributes(dbf, bytes("NOT-AN-ENCODING")).encoding).toBe(
+ "utf-8",
+ );
+ });
+
+ it("falls back to the parser's own default when nothing indicates otherwise", () => {
+ // A file carrying no encoding information decodes exactly as it did before
+ // any of this existed.
+ const dbf = dbfLike(new Uint8Array([0x4d, 0x69, 0xf1, 0x6f]));
+ expect(prepareAttributes(dbf).encoding).toBe("windows-1252");
+ });
+
+ it("passes a header it cannot make sense of through untouched", () => {
+ // Rewriting on a guess would corrupt whatever this actually is; the parser
+ // gets to report it instead.
+ const nonsense = new Uint8Array(40);
+ nonsense[8] = 0xff;
+ nonsense[9] = 0xff;
+ const prepared = prepareAttributes(nonsense);
+
+ expect(prepared.dbf).toBe(nonsense);
+ expect(prepared.encoding).toBe("windows-1252");
+ });
+
+ it("reports nothing to read for an absent .dbf", () => {
+ expect(prepareAttributes(undefined)).toEqual({ dbf: null, encoding: null });
+ expect(prepareAttributes(new Uint8Array())).toEqual({
+ dbf: null,
+ encoding: null,
+ });
+ });
+});
diff --git a/reactapp/__tests__/components/map/shapefile/index.test.js b/reactapp/__tests__/components/map/shapefile/index.test.js
index 13e291f3..b49b289f 100644
--- a/reactapp/__tests__/components/map/shapefile/index.test.js
+++ b/reactapp/__tests__/components/map/shapefile/index.test.js
@@ -7,9 +7,10 @@ import { interpretShapefile } from "components/map/shapefile/index";
// into its filename string, so a binary fixture cannot be `import`ed.
const FIXTURES = path.join(__dirname, "../../../utilities/fixtures/shapefile");
-function load(name) {
+function load(name, { without = [] } = {}) {
const components = {};
- ["shp", "dbf", "prj", "shx"].forEach((extension) => {
+ ["shp", "dbf", "prj", "shx", "cpg"].forEach((extension) => {
+ if (without.includes(extension)) return;
const file = path.join(FIXTURES, `${name}.${extension}`);
if (fs.existsSync(file)) {
components[extension] = new Uint8Array(fs.readFileSync(file));
@@ -261,3 +262,72 @@ describe("interpretShapefile — projection field accepts a definition", () => {
expect(result.projectionCode).toBe("EPSG:5070");
});
});
+
+describe("interpretShapefile — attribute text", () => {
+ // The fixture is NUL-padded and UTF-8, which is what Natural Earth ships. Both
+ // properties are asserted on the raw bytes first: the parser pads with
+ // trim(), which does not remove NUL, and defaults to windows-1252 regardless
+ // of what the source says -- so a fixture that had lost either property would
+ // let these tests pass while the defect stood.
+ it("has a fixture that actually carries the defect", () => {
+ const dbf = load("encoded").dbf;
+ const headerLength = dbf[8] | (dbf[9] << 8);
+ const records = dbf.subarray(headerLength);
+
+ expect(
+ Array.from(records).filter((byte) => byte === 0).length,
+ ).toBeGreaterThan(0);
+ expect(Array.from(records).some((byte) => byte > 0x7f)).toBe(true);
+ expect(new TextDecoder("windows-1252").decode(records)).not.toContain(
+ "明尼苏达州",
+ );
+ });
+
+ it("reads non-ASCII values in the encoding the .cpg declares", async () => {
+ const result = await interpretShapefile(load("encoded"));
+
+ expect(result.error).toBeUndefined();
+ const [feature] = result.featureCollection.features;
+ expect(feature.properties.NAME).toBe("Miñnesota 明尼苏达州");
+ });
+
+ it("strips NUL padding rather than carrying it into the value", async () => {
+ // Left in place this renders as a run of tofu boxes after the text, which is
+ // how the defect was reported.
+ const result = await interpretShapefile(load("encoded"));
+
+ const [feature] = result.featureCollection.features;
+ expect(feature.properties.NAME).not.toContain(String.fromCharCode(0));
+ expect(feature.properties.NAME).toHaveLength("Miñnesota 明尼苏达州".length);
+ });
+
+ it("reads an all-padding field as absent rather than as padding", async () => {
+ // A NUL-filled field is a truthy string to the parser, so an empty value
+ // arrives as content -- boxes in a popup, and a style rule that matches it.
+ const result = await interpretShapefile(load("encoded"));
+
+ const [feature] = result.featureCollection.features;
+ expect(feature.properties.LOCALNAME).toBeNull();
+ });
+
+ it("reads a NUL-padded numeric field as its number", async () => {
+ // `+value` on a NUL-padded number is NaN, which the parser reports as null.
+ // Nothing surfaces -- the attribute is simply missing.
+ const result = await interpretShapefile(load("encoded"));
+
+ const [feature] = result.featureCollection.features;
+ expect(feature.properties.POP).toBe(5707390);
+ });
+
+ it("falls back to sniffing when the source carries no .cpg", async () => {
+ // Most shapefiles have no .cpg at all, so the declared path alone would
+ // leave the common case broken.
+ const result = await interpretShapefile(
+ load("encoded", { without: ["cpg"] }),
+ );
+
+ expect(result.error).toBeUndefined();
+ const [feature] = result.featureCollection.features;
+ expect(feature.properties.NAME).toBe("Miñnesota 明尼苏达州");
+ });
+});
diff --git a/reactapp/__tests__/components/map/shapefile/siblings.test.js b/reactapp/__tests__/components/map/shapefile/siblings.test.js
index 45199922..7a90633d 100644
--- a/reactapp/__tests__/components/map/shapefile/siblings.test.js
+++ b/reactapp/__tests__/components/map/shapefile/siblings.test.js
@@ -89,6 +89,7 @@ describe("deriveSiblingUrls", () => {
dbf: "https://example.org/data/basins.dbf",
prj: "https://example.org/data/basins.prj",
shx: "https://example.org/data/basins.shx",
+ cpg: "https://example.org/data/basins.cpg",
});
});
diff --git a/reactapp/__tests__/utilities/fixtures/shapefile/encoded.cpg b/reactapp/__tests__/utilities/fixtures/shapefile/encoded.cpg
new file mode 100644
index 00000000..3ad133c0
--- /dev/null
+++ b/reactapp/__tests__/utilities/fixtures/shapefile/encoded.cpg
@@ -0,0 +1 @@
+UTF-8
\ No newline at end of file
diff --git a/reactapp/__tests__/utilities/fixtures/shapefile/encoded.dbf b/reactapp/__tests__/utilities/fixtures/shapefile/encoded.dbf
new file mode 100644
index 0000000000000000000000000000000000000000..415d0cbb75ff22173e65ee92816d8894b60e7d71
GIT binary patch
literal 204
zcmZRstL#e#|>O{DAhi>31iyyE~xsv1>;W1sTJI%`5=*%NnHs
literal 0
HcmV?d00001
diff --git a/reactapp/__tests__/utilities/fixtures/shapefile/encoded.shx b/reactapp/__tests__/utilities/fixtures/shapefile/encoded.shx
new file mode 100644
index 0000000000000000000000000000000000000000..7caa7355135165fcf90a8c78102d6c47a8ad73d0
GIT binary patch
literal 108
wcmZQzQ0HR64$NLKGcYg$<>tL#e#|>O{DAhi>31iyyE~xsv1>;WH3IUu05?MtVgLXD
literal 0
HcmV?d00001
diff --git a/reactapp/__tests__/utilities/fixtures/shapefile/generate.py b/reactapp/__tests__/utilities/fixtures/shapefile/generate.py
index f266af23..cefd8203 100644
--- a/reactapp/__tests__/utilities/fixtures/shapefile/generate.py
+++ b/reactapp/__tests__/utilities/fixtures/shapefile/generate.py
@@ -72,9 +72,75 @@ def points(writer):
writer.record("06730500", 2.5)
+# Non-ASCII attribute values in a NUL-padded .dbf, which is what Natural Earth
+# ships and what no amount of String.prototype.trim() will clean up. Written with
+# a .cpg so both halves of the encoding path have a fixture: the declared
+# encoding, and (by deleting the .cpg in a test) the sniffed one.
+def encoded(writer):
+ writer.field("NAME", "C", 40)
+ writer.field("LOCALNAME", "C", 24)
+ writer.field("POP", "N", 10)
+ writer.point(-93.364, 46.0592)
+ writer.record("Miñnesota 明尼苏达州", "", 5707390)
+
+
+def nul_pad_dbf(path):
+ """Rewrite the .dbf's space padding as NUL padding, in place.
+
+ pyshp pads with spaces, which trim() removes; the defect under test only
+ appears with NUL padding. Only each field's own padding run is rewritten --
+ trailing for character fields, leading for the right-justified numeric ones
+ -- so the values themselves and the binary header are left alone.
+ """
+ with open(path, "rb") as handle:
+ data = bytearray(handle.read())
+
+ header_len, record_len = int.from_bytes(data[8:10], "little"), int.from_bytes(
+ data[10:12], "little"
+ )
+ n_records = int.from_bytes(data[4:8], "little")
+
+ fields, offset = [], 32
+ while data[offset] != 0x0D:
+ fields.append((chr(data[offset + 11]), data[offset + 16]))
+ offset += 32
+
+ for record in range(n_records):
+ base = header_len + record * record_len + 1 # +1 skips the deletion flag
+ cursor = 0
+ for kind, length in fields:
+ start = base + cursor
+ chunk = data[start : start + length]
+ if kind == "C":
+ stripped = chunk.rstrip(b" ")
+ chunk = stripped + b"\x00" * (length - len(stripped))
+ else:
+ stripped = chunk.lstrip(b" ")
+ chunk = b"\x00" * (length - len(stripped)) + stripped
+ data[start : start + length] = chunk
+ cursor += length
+
+ with open(path, "wb") as handle:
+ handle.write(data)
+
+
+def write_encoded():
+ path = os.path.join(HERE, "encoded")
+ writer = shapefile.Writer(path, encoding="utf-8")
+ encoded(writer)
+ writer.close()
+ with open(path + ".prj", "w") as handle:
+ handle.write(ESRI_ALBERS_PRJ)
+ with open(path + ".cpg", "w") as handle:
+ handle.write("UTF-8")
+ nul_pad_dbf(path + ".dbf")
+ return {"note": "NUL-padded utf-8 attributes; oracle asserted in the JS test"}
+
+
oracle = {
"holes": write("holes", holes),
"multipart": write("multipart", multipart),
"points": write("points", points),
+ "encoded": write_encoded(),
}
print(json.dumps(oracle, indent=2, sort_keys=True))
diff --git a/reactapp/components/map/shapefile/acquire.js b/reactapp/components/map/shapefile/acquire.js
index 5097613b..110d55e0 100644
--- a/reactapp/components/map/shapefile/acquire.js
+++ b/reactapp/components/map/shapefile/acquire.js
@@ -1,6 +1,7 @@
import {
validateSourceUrl,
deriveSiblingUrls,
+ COMPONENT_EXTENSIONS,
} from "components/map/shapefile/siblings";
import {
unzipShapefileComponents,
@@ -104,9 +105,11 @@ async function acquireSiblings(url, signal, maxBytes) {
const components = {};
// Sequential rather than concurrent: the .shp is required, so there is no
- // point paying for the other three before knowing it exists, and a shared
- // budget is simpler to reason about when only one request is in flight.
- for (const extension of ["shp", "dbf", "prj", "shx"]) {
+ // point paying for the others before knowing it exists, and a shared budget is
+ // simpler to reason about when only one request is in flight. Driven off the
+ // component set so adding a component does not mean remembering to add it in
+ // two places.
+ for (const extension of COMPONENT_EXTENSIONS) {
const fetched = await fetchBytes(derived[extension], signal);
if (fetched.cancelled) return fetched;
if (fetched.error) {
diff --git a/reactapp/components/map/shapefile/attributes.js b/reactapp/components/map/shapefile/attributes.js
new file mode 100644
index 00000000..da7aa1c0
--- /dev/null
+++ b/reactapp/components/map/shapefile/attributes.js
@@ -0,0 +1,181 @@
+// How a .dbf's text is decoded and de-padded before the parser sees it.
+//
+// Two independent defects live here, both of which surface as unreadable
+// attribute values rather than as errors, which is why they are handled at the
+// bytes rather than left to the parser:
+//
+// 1. The parser strips padding with String.prototype.trim(), and NUL is not
+// whitespace in JavaScript. A .dbf that pads its character fields with NUL
+// rather than spaces -- Natural Earth's do -- yields values carrying their
+// padding, which renders as a run of tofu boxes. Worse, an empty field
+// becomes a truthy string of NULs instead of null, and a NUL-padded numeric
+// field fails `+value` and so reads as null with nothing reported.
+// 2. The parser defaults to windows-1252. A UTF-8 .dbf decoded that way turns
+// every non-ASCII name into mojibake.
+
+const DEFAULT_ENCODING = "windows-1252";
+
+// A DBF records its own header length at offset 8, and records begin there.
+// Everything before it is binary -- field lengths, decimal counts, flags -- and
+// everything after it is text for every field type this parser reads. That
+// split is what makes the record region safe to rewrite and safe to sniff, and
+// why neither is done to the whole buffer.
+const HEADER_LENGTH_OFFSET = 8;
+const MIN_HEADER_LENGTH = 33;
+
+const NUL = 0x00;
+const SPACE = 0x20;
+
+// What a .cpg contains varies by whatever wrote the shapefile: a codepage
+// number, an ESRI name, or an IANA label. Only the forms that appear in practice
+// are mapped -- anything else is offered to TextDecoder directly, so an unusual
+// but valid label still works without being enumerated here.
+const CODE_PAGE_ALIASES = {
+ 65001: "utf-8",
+ UTF8: "utf-8",
+ 1252: "windows-1252",
+ ANSI: "windows-1252",
+ LATIN1: "iso-8859-1",
+ 8859: "iso-8859-1",
+};
+
+// A label is usable only if TextDecoder accepts it. Checked here rather than
+// left to the parser, which builds its decoder inside the read and would surface
+// a bad .cpg as an unreadable-geometry error naming the wrong component.
+function usableLabel(label) {
+ if (!label || typeof TextDecoder === "undefined") return null;
+ try {
+ new TextDecoder(label);
+ return label;
+ } catch {
+ return null;
+ }
+}
+
+/**
+ * The encoding a .cpg names, or null when it names nothing usable.
+ *
+ * @param {Uint8Array} [cpgBytes]
+ * @returns {string|null}
+ */
+export function encodingFromCodePage(cpgBytes) {
+ if (!cpgBytes?.length) return null;
+
+ // Only printable ASCII is kept. A .cpg is commonly written with a trailing
+ // newline or NUL, and neither trim() nor TextDecoder would reject the result
+ // -- it would just be an unusable label for a reason nothing could report.
+ const raw = Array.from(cpgBytes)
+ .filter((byte) => byte > SPACE && byte < 0x7f)
+ .map((byte) => String.fromCharCode(byte))
+ .join("");
+ if (raw === "") return null;
+
+ const upper = raw.toUpperCase();
+ // Forms like "ANSI1252" carry the number alongside the name, so a trailing
+ // bare number is tried as a codepage before the string as a whole.
+ const digits = upper.match(/(\d{3,5})$/)?.[1];
+
+ return (
+ usableLabel(CODE_PAGE_ALIASES[upper]) ??
+ usableLabel(digits ? CODE_PAGE_ALIASES[digits] : null) ??
+ usableLabel(raw.toLowerCase()) ??
+ null
+ );
+}
+
+// Where the records start, or null when the header does not describe a .dbf this
+// can reason about -- in which case the buffer is passed through untouched
+// rather than rewritten on a guess.
+function recordRegionOffset(bytes) {
+ if (bytes.length <= HEADER_LENGTH_OFFSET + 1) return null;
+ const headerLength =
+ bytes[HEADER_LENGTH_OFFSET] | (bytes[HEADER_LENGTH_OFFSET + 1] << 8);
+ if (headerLength < MIN_HEADER_LENGTH || headerLength >= bytes.length) {
+ return null;
+ }
+ return headerLength;
+}
+
+/**
+ * Whether the record region is UTF-8.
+ *
+ * UTF-8 is self-validating, so an invalid sequence is proof the bytes are not
+ * UTF-8. The converse is weaker but strong enough: multi-byte UTF-8 sequences
+ * arising by chance across a whole file of legacy text is vanishingly unlikely.
+ * Pure ASCII is deliberately not evidence -- it decodes identically either way,
+ * so it must not tip the decision.
+ *
+ * @param {Uint8Array} bytes
+ * @param {number} from Offset of the record region.
+ * @returns {boolean}
+ */
+export function looksLikeUtf8(bytes, from) {
+ if (typeof TextDecoder === "undefined") return false;
+
+ let sawNonAscii = false;
+ for (let index = from; index < bytes.length; index += 1) {
+ if (bytes[index] > 0x7f) {
+ sawNonAscii = true;
+ break;
+ }
+ }
+ if (!sawNonAscii) return false;
+
+ try {
+ new TextDecoder("utf-8", { fatal: true }).decode(bytes.subarray(from));
+ return true;
+ } catch {
+ return false;
+ }
+}
+
+// NUL padding rewritten to spaces, which the parser's trim() does remove. Done
+// to a copy rather than in place: these buffers are cached and handed back to
+// callers as the bytes the host served, and quietly differing from that is a
+// trap for anything reading them later.
+function depadRecords(bytes, from) {
+ let padded = false;
+ for (let index = from; index < bytes.length; index += 1) {
+ if (bytes[index] === NUL) {
+ padded = true;
+ break;
+ }
+ }
+ if (!padded) return bytes;
+
+ const copy = bytes.slice();
+ for (let index = from; index < copy.length; index += 1) {
+ if (copy[index] === NUL) copy[index] = SPACE;
+ }
+ return copy;
+}
+
+/**
+ * The .dbf bytes and encoding to hand the parser.
+ *
+ * The .cpg wins when it names something usable, being the only explicit
+ * statement of intent available. Otherwise the record region is sniffed for
+ * UTF-8, and failing that the parser's own default stands -- so a file carrying
+ * no encoding information decodes exactly as it did before any of this existed.
+ *
+ * The legacy language-driver byte in the header is deliberately not consulted:
+ * its two common values both mean windows-1252, which is already the fallback,
+ * and the rest name codepages TextDecoder cannot construct.
+ *
+ * @param {Uint8Array} [dbfBytes]
+ * @param {Uint8Array} [cpgBytes]
+ * @returns {{dbf: Uint8Array|null, encoding: string|null}}
+ */
+export function prepareAttributes(dbfBytes, cpgBytes) {
+ if (!dbfBytes?.length) return { dbf: null, encoding: null };
+
+ const recordsAt = recordRegionOffset(dbfBytes);
+ const declared = encodingFromCodePage(cpgBytes);
+ const sniffed =
+ recordsAt !== null && looksLikeUtf8(dbfBytes, recordsAt) ? "utf-8" : null;
+
+ return {
+ dbf: recordsAt === null ? dbfBytes : depadRecords(dbfBytes, recordsAt),
+ encoding: declared ?? sniffed ?? DEFAULT_ENCODING,
+ };
+}
diff --git a/reactapp/components/map/shapefile/index.js b/reactapp/components/map/shapefile/index.js
index 00c7e962..feb5c51d 100644
--- a/reactapp/components/map/shapefile/index.js
+++ b/reactapp/components/map/shapefile/index.js
@@ -3,6 +3,7 @@ import {
ensureProjection,
registerProjectionDefinition,
} from "components/map/projections";
+import { prepareAttributes } from "components/map/shapefile/attributes";
/**
* Turn shapefile component buffers into a GeoJSON feature collection carrying
@@ -44,11 +45,18 @@ export async function interpretShapefile(
// if they were measured against the artifact that actually ships.
const { read } = await import("shapefile/dist/shapefile.js");
+ // The .dbf is decoded and de-padded before the parser sees it: the parser
+ // defaults to windows-1252 regardless of what the source says it wrote, and
+ // strips padding with trim(), which leaves NUL padding in place. Both failures
+ // are silent -- they produce readable-looking attribute values that are wrong.
+ const attributes = prepareAttributes(components.dbf, components.cpg);
+
let collection;
try {
collection = await read(
toArrayBuffer(components.shp),
- components.dbf ? toArrayBuffer(components.dbf) : undefined,
+ attributes.dbf ? toArrayBuffer(attributes.dbf) : undefined,
+ attributes.encoding ? { encoding: attributes.encoding } : undefined,
);
} catch (error) {
return {
diff --git a/reactapp/components/map/shapefile/siblings.js b/reactapp/components/map/shapefile/siblings.js
index 279dcf26..d6a6ae94 100644
--- a/reactapp/components/map/shapefile/siblings.js
+++ b/reactapp/components/map/shapefile/siblings.js
@@ -1,7 +1,9 @@
// The components of an unzipped shapefile. `shp` carries geometry, `dbf`
-// attributes, `prj` the coordinate reference system as WKT, and `shx` the record
-// index.
-export const COMPONENT_EXTENSIONS = ["shp", "dbf", "prj", "shx"];
+// attributes, `prj` the coordinate reference system as WKT, `shx` the record
+// index, and `cpg` the character encoding the `dbf` was written in. Only `shp`
+// is required; `shp` is first because the fetch loop treats it as the one whose
+// absence is fatal.
+export const COMPONENT_EXTENSIONS = ["shp", "dbf", "prj", "shx", "cpg"];
const ALLOWED_PROTOCOLS = ["http:", "https:"];
From c9a85cf8b92d3a124b4036379c4aade7b6ace03f Mon Sep 17 00:00:00 2001
From: Corey Krewson
Date: Tue, 25 Aug 2026 14:41:28 -0700
Subject: [PATCH 14/18] Cover the untested zarr controller and conversion paths
Closes the last uncovered lines in controllers.py and zarr_utils.py, both of
which reached main without tests.
controllers.py: the 500 path where an unexpected conversion failure is logged
rather than forwarded, zarr_meta's missing-src rejection, and its two error
exits -- 502 when the store cannot be opened, 400 when the metadata read
rejects it. The split matters and is now asserted: the open failure's message
is swallowed because it can name internal hosts, while the metadata error is
forwarded because it describes something the author chose and can act on.
zarr_utils.py: open_store (previously always mocked) is now exercised against a
stubbed FsspecStore -- that it opens read-only, that it retries a transient
failure, and that a persistent one surfaces as StoreOpenError specifically,
since the API layer maps that to 502 rather than 400. read_cog gets the same
treatment plus its lru_cache, which is what stops a COG's several range reads
from each re-opening the store. read_metadata gains the explicit-variable
branch, its unknown-variable and missing-transform rejections, and the
unsupported-ndim path through _grid_dims. parse_byte_range gains the
non-numeric-bound cases that reach its ValueError handler.
Verified by mutation rather than by coverage alone: nine of ten seeded defects
in these paths are caught. The tenth is the `n <= 0` guard in
parse_byte_range, which no input can distinguish -- with a non-positive suffix
length `start` lands at or past `total`, which the bounds check below already
rejects. The test pins the observable contract instead.
Co-Authored-By: Claude Opus 5 (1M context)
---
.../integrated_tests/test_controllers.py | 51 ++++++
.../tests/unit_tests/test_zarr_utils.py | 152 ++++++++++++++++++
2 files changed, 203 insertions(+)
diff --git a/tethysapp/tethysdash/tests/integrated_tests/test_controllers.py b/tethysapp/tethysdash/tests/integrated_tests/test_controllers.py
index d3cb9cfa..d71119e4 100644
--- a/tethysapp/tethysdash/tests/integrated_tests/test_controllers.py
+++ b/tethysapp/tethysdash/tests/integrated_tests/test_controllers.py
@@ -2666,3 +2666,54 @@ def test_zarr_meta_unsafe_url_422(mocker):
)
request = RequestFactory().get("/zarr/meta", {"src": "http://169.254.169.254"})
assert zarr_meta(request).status_code == 422
+
+
+def test_zarr_cog_unexpected_failure_500(mocker):
+ # Anything that is not a recognised zarr failure is a bug in the conversion,
+ # and its message can name internal paths or store internals -- so it is
+ # logged server-side and the caller gets a fixed string. The distinction from
+ # the 400 above is what keeps that message from leaking.
+ mocker.patch("tethysapp.tethysdash.controllers.validate_public_url")
+ mocker.patch(
+ "tethysapp.tethysdash.controllers.read_cog",
+ side_effect=RuntimeError("rasterio failed at /srv/internal/path"),
+ )
+ request = RequestFactory().get("/zarr/cog", {"src": "https://x", "variable": "t"})
+ response = zarr_cog(request)
+ assert response.status_code == 500
+ assert json.loads(response.content) == {"error": "failed to convert store"}
+
+
+def test_zarr_meta_requires_src():
+ response = zarr_meta(RequestFactory().get("/zarr/meta"))
+ assert response.status_code == 400
+ assert "src" in json.loads(response.content)["error"]
+
+
+@pytest.mark.parametrize(
+ "error",
+ # StoreOpenError subclasses ZarrCogError, so the handler catches the base
+ # class and both a network failure and a malformed store land on 502.
+ [StoreOpenError("dns failure for internal.host"), ZarrCogError("not a zarr store")],
+)
+def test_zarr_meta_store_open_failure_502(mocker, error):
+ mocker.patch("tethysapp.tethysdash.controllers.validate_public_url")
+ mocker.patch("tethysapp.tethysdash.controllers.open_store", side_effect=error)
+ response = zarr_meta(RequestFactory().get("/zarr/meta", {"src": "https://x"}))
+ assert response.status_code == 502
+ # The upstream message is not forwarded; it can name internal hosts.
+ assert json.loads(response.content) == {"error": "could not open store"}
+
+
+def test_zarr_meta_read_metadata_error_400(mocker):
+ # Unlike the open failure, this message is forwarded: it describes something
+ # about the store the author chose and can act on.
+ mocker.patch("tethysapp.tethysdash.controllers.validate_public_url")
+ mocker.patch("tethysapp.tethysdash.controllers.open_store", return_value=object())
+ mocker.patch(
+ "tethysapp.tethysdash.controllers.read_metadata",
+ side_effect=ZarrCogError("could not determine a griddable variable"),
+ )
+ response = zarr_meta(RequestFactory().get("/zarr/meta", {"src": "https://x"}))
+ assert response.status_code == 400
+ assert "griddable" in json.loads(response.content)["error"]
diff --git a/tethysapp/tethysdash/tests/unit_tests/test_zarr_utils.py b/tethysapp/tethysdash/tests/unit_tests/test_zarr_utils.py
index 8513a1f7..728bba31 100644
--- a/tethysapp/tethysdash/tests/unit_tests/test_zarr_utils.py
+++ b/tethysapp/tethysdash/tests/unit_tests/test_zarr_utils.py
@@ -205,6 +205,12 @@ def always_fail():
("bytes=50-", 100, (50, 99)), # open-ended
("bytes=-10", 100, (90, 99)), # suffix
("bytes=200-300", 100, None), # start past EOF
+ ("bytes=-0", 100, None), # suffix of zero bytes asks for nothing
+ # A non-numeric bound is refused rather than raising out of the view: the
+ # header is caller-supplied, so a bad one must degrade to a full body.
+ ("bytes=0-abc", 100, None),
+ ("bytes=abc-def", 100, None),
+ ("bytes=-xyz", 100, None),
],
)
def test_parse_byte_range(header, total, expected):
@@ -218,3 +224,149 @@ def test_build_cog_embeds_band_statistics():
# slice 0's only wet cell is 2.5 (see _make_group), so min == max == 2.5
assert float(tags["STATISTICS_MINIMUM"]) == pytest.approx(2.5)
assert float(tags["STATISTICS_MAXIMUM"]) == pytest.approx(2.5)
+
+
+class _StubFsspecStore:
+ """Stands in for ``FsspecStore`` so ``open_store`` can be driven without a
+ network. Patched onto the module rather than onto zarr's own class, which
+ would mutate a dependency's internals for the duration of the test."""
+
+ opened = []
+
+ @classmethod
+ def from_url(cls, src):
+ cls.opened.append(src)
+ return f"store:{src}"
+
+
+def test_open_store_opens_the_url_read_only(monkeypatch):
+ stub = type("Stub", (_StubFsspecStore,), {"opened": []})
+ monkeypatch.setattr(zu, "FsspecStore", stub)
+ group = object()
+ seen = {}
+
+ def fake_open_group(store, mode):
+ seen.update(store=store, mode=mode)
+ return group
+
+ monkeypatch.setattr(zu.zarr, "open_group", fake_open_group)
+
+ assert zu.open_store("https://host/s.zarr") is group
+ assert stub.opened == ["https://host/s.zarr"]
+ # Read-only is the whole contract here: the store belongs to whoever
+ # published it, and this endpoint takes the URL from a query parameter.
+ assert seen == {"store": "store:https://host/s.zarr", "mode": "r"}
+
+
+def test_open_store_retries_a_transient_failure(monkeypatch):
+ monkeypatch.setattr(zu.time, "sleep", lambda _s: None)
+ stub = type("Stub", (_StubFsspecStore,), {"opened": []})
+ monkeypatch.setattr(zu, "FsspecStore", stub)
+ attempts = {"n": 0}
+ group = object()
+
+ def flaky_open_group(store, mode):
+ attempts["n"] += 1
+ if attempts["n"] < 3:
+ raise ConnectionError("transient")
+ return group
+
+ monkeypatch.setattr(zu.zarr, "open_group", flaky_open_group)
+
+ # A single range read over HTTP fails transiently often enough that one
+ # failure must not sink the request.
+ assert zu.open_store("https://host/s.zarr") is group
+ assert attempts["n"] == 3
+
+
+def test_open_store_wraps_a_persistent_failure(monkeypatch):
+ monkeypatch.setattr(zu.time, "sleep", lambda _s: None)
+
+ class _Boom(_StubFsspecStore):
+ @classmethod
+ def from_url(cls, src):
+ raise OSError("dns failure")
+
+ monkeypatch.setattr(zu, "FsspecStore", _Boom)
+
+ # Reported as StoreOpenError specifically, because the API layer maps that to
+ # 502 (the store is unreachable) rather than 400 (the request is wrong).
+ with pytest.raises(zu.StoreOpenError, match="could not open zarr store") as caught:
+ zu.open_store("https://host/s.zarr")
+ assert isinstance(caught.value, ZarrCogError)
+ # The original is kept as the cause so a server log still names the reason.
+ assert isinstance(caught.value.__cause__, OSError)
+
+
+def test_read_cog_builds_from_the_opened_store(monkeypatch):
+ zu.read_cog.cache_clear()
+ monkeypatch.setattr(zu, "open_store", lambda src: f"group:{src}")
+ calls = []
+
+ def fake_build(group, variable, index, *, mask_below=None):
+ calls.append((group, variable, index, mask_below))
+ return b"COGBYTES"
+
+ monkeypatch.setattr(zu, "build_cog", fake_build)
+
+ assert zu.read_cog("https://a.zarr", "depth", 1, 0.5) == b"COGBYTES"
+ assert calls == [("group:https://a.zarr", "depth", 1, 0.5)]
+ zu.read_cog.cache_clear()
+
+
+def test_read_cog_caches_per_argument_set(monkeypatch):
+ zu.read_cog.cache_clear()
+ opens = {"n": 0}
+
+ def counting_open(src):
+ opens["n"] += 1
+ return "group"
+
+ monkeypatch.setattr(zu, "open_store", counting_open)
+ monkeypatch.setattr(zu, "build_cog", lambda *a, **k: b"COGBYTES")
+
+ zu.read_cog("https://a.zarr", "depth", 0, None)
+ zu.read_cog("https://a.zarr", "depth", 0, None)
+ # A map layer reads one COG over several range requests, so without the cache
+ # each of those would re-open the store and rebuild the whole file.
+ assert opens["n"] == 1
+ zu.read_cog("https://a.zarr", "depth", 1, None)
+ assert opens["n"] == 2
+ zu.read_cog.cache_clear()
+
+
+def test_read_metadata_uses_the_requested_variable():
+ g = _make_group()
+ g.create_array("elevation", shape=(2, 3), dtype="float32")
+ # Auto-selection would pick a griddable array in hash-dependent order, and
+ # "depth" is 3x4x5 -- so the shape is what proves the request was honored.
+ meta = read_metadata(g, variable="elevation")
+ assert meta["grid_shape"] == [2, 3]
+ assert meta["slice_count"] == 1
+
+
+def test_read_metadata_unknown_requested_variable_raises():
+ # Distinct from "could not determine a griddable variable": the caller named
+ # something, and saying it is absent is more use than re-asking for a name.
+ with pytest.raises(ZarrCogError, match="'nope' not found"):
+ read_metadata(_make_group(), variable="nope")
+
+
+def test_read_metadata_missing_transform_raises():
+ g = _make_group()
+ del g.attrs["transform"]
+ # read_metadata needs only the transform -- it reports crs as-is, so unlike
+ # build_cog it does not require one.
+ with pytest.raises(ZarrCogError, match="transform"):
+ read_metadata(g)
+
+
+def test_read_metadata_rejects_unsupported_ndim():
+ g = zarr.open_group(store=zarr.storage.MemoryStore(), mode="w")
+ g.attrs["crs"] = CRS
+ g.attrs["transform"] = TRANSFORM
+ g.create_array("cube", shape=(2, 3, 4, 5), dtype="float32")
+ # Named explicitly because auto-selection skips a 4-D array and would report
+ # that nothing griddable was found instead.
+ with pytest.raises(ZarrCogError, match=r"2D .* or 3D"):
+ read_metadata(g, variable="cube")
From 496d8d898da2e22687ac8e1aaccdcfbacf136b1f Mon Sep 17 00:00:00 2001
From: Corey Krewson
Date: Tue, 25 Aug 2026 15:42:49 -0700
Subject: [PATCH 15/18] fix(review): close the size ceiling, component
selection, and loader lifecycle gaps
Applied from a multi-agent review of the shapefile branch. Every item below was
either reproduced empirically or confirmed by an independent validator before
being changed.
The 25 MB ceiling did not bind. It was charged against the size each member's
local header declares, and fflate's inflater ignores that number entirely -- so
a member that under-declared expanded without limit. A 41 KB archive with four
bytes edited materialized 40 MB while the budget observed 111. The declared size
is now a fast path that refuses an oversized member before inflating it, then is
reconciled against the bytes that actually arrive, so a lying header buys
nothing. cache.js's "already under the ceiling by construction" comment
depended on this holding.
Components were classified by extension alone. A shapefile zipped with macOS
Finder ships "__MACOSX/._basins.shp", which counted as a second shapefile and
got the archive rejected as ambiguous -- telling the author to point at a single
shapefile, which is what they had done. AppleDouble twins and directory entries
are now skipped, and parts are selected by the .shp's own directory and stem, so
an unrelated .dbf elsewhere in the archive can no longer become the attribute
table (previously whichever .dbf appeared last simply won).
Adding .cpg to the fetched component set widened an existing defect into a
likely one: only 404 counted as absence, and an S3 bucket without ListBucket
returns 403 for a missing object -- so a layer that worked before would fail
over a file it never needed, and most shapefiles ship no .cpg. Any client error
on .cpg or .shx is now absence. .prj and .dbf stay strict, because treating a
transient 403 on those as absence would draw features in the wrong place with no
error at all.
deriveSiblingUrls lower-cased every extension, and the .shp is fetched from that
table too -- so ".SHP" 404'd its own required component on any case-sensitive
host. Siblings now follow the case the author wrote, and the component they
named is requested at exactly the URL they gave. The test asserting the
lower-casing described it as normalization; it is not normalizable, so the test
was wrong rather than the behavior.
The loader had no notion of which invocation was current, which produced three
failures at once. Aborting stopped the fetch but not the CPU-bound parse, so a
discarded run finished and wrote under its layer name -- and status is keyed by
name, so it erased a live layer's error and left a blank layer reporting
nothing. Retry is never disabled while a load runs and refresh() exists to
re-invoke the loader, so two runs overlapped and the second orphaned the first's
controller. And nothing caught a throw: OpenLayers calls the loader without a
catch of its own, so a rejected dynamic import (a stale tab after a deploy) left
the layer on "loading" forever with no error and no retry. A per-invocation
currency check now makes a superseded run silent, reset() aborts before
restarting, and an escaping throw is reported as a retryable failure.
Removed the Shapefile arms of getStyleFields/getLayerAttributes and
getShapefileLayerAttributes: both panes short-circuit on shapefileDiscovery and
return before reaching them, so the path was unreachable -- and its six direct
unit tests reported confidence in code production never ran. Replaced with
assertions on the short-circuit itself, which is what was actually untested.
AttributesPane's effect omitted shapefileDiscovery from its deps, so fields read
after that tab had been visited never arrived. StylePane already listed it.
Co-Authored-By: Claude Opus 5 (1M context)
---
.../components/map/shapefile/acquire.test.js | 70 +++++++++
.../components/map/shapefile/siblings.test.js | 28 +++-
.../components/map/shapefile/unzip.test.js | 131 +++++++++++++++++
.../components/map/shapefileDispatch.test.js | 133 +++---------------
.../components/map/shapefileSource.test.js | 121 ++++++++++++++++
reactapp/components/map/ModuleLoader.js | 103 +++++++++-----
reactapp/components/map/shapefile/acquire.js | 30 ++--
reactapp/components/map/shapefile/siblings.js | 17 ++-
reactapp/components/map/shapefile/unzip.js | 63 +++++++--
reactapp/components/map/utilities.js | 51 +------
.../modals/MapLayer/AttributesPane.js | 11 +-
11 files changed, 545 insertions(+), 213 deletions(-)
diff --git a/reactapp/__tests__/components/map/shapefile/acquire.test.js b/reactapp/__tests__/components/map/shapefile/acquire.test.js
index 3fbd8037..e569a68b 100644
--- a/reactapp/__tests__/components/map/shapefile/acquire.test.js
+++ b/reactapp/__tests__/components/map/shapefile/acquire.test.js
@@ -355,3 +355,73 @@ describe("acquireComponents — defaults", () => {
expect(DEFAULT_MAX_BYTES).toBe(25 * MB);
});
});
+
+describe("acquireComponents — a component the host will not serve", () => {
+ // Hosts disagree about what a missing object is. An S3 bucket without
+ // ListBucket returns 403 rather than 404, and adding .cpg to the fetched set
+ // made that the common case: most shapefiles ship no .cpg at all, so a layer
+ // that worked before would start failing over a file it never needed.
+ function respondingWith(statuses) {
+ return (url) => {
+ const extension = url.split("?")[0].split(".").pop();
+ if (statuses[extension] !== undefined) {
+ return Promise.resolve(respond({ status: statuses[extension] }));
+ }
+ return Promise.resolve(
+ respond({
+ contentType: "application/octet-stream",
+ body: bytes(`${extension.toUpperCase()}BODY`),
+ }),
+ );
+ };
+ }
+
+ it("treats a 403 on .cpg as absence rather than failing the layer", async () => {
+ fetchMock.mockImplementation(respondingWith({ cpg: 403 }));
+
+ const result = await acquireComponents("https://example.org/basins.shp");
+
+ expect(result.error).toBeUndefined();
+ expect(result.components.cpg).toBeUndefined();
+ expect(result.components.shp).toBeDefined();
+ });
+
+ it("treats a 403 on .shx as absence too", async () => {
+ fetchMock.mockImplementation(respondingWith({ shx: 403 }));
+
+ const result = await acquireComponents("https://example.org/basins.shp");
+
+ expect(result.error).toBeUndefined();
+ expect(result.components.shp).toBeDefined();
+ });
+
+ it("still fails the layer on a 403 for .prj", async () => {
+ // Absence of a .prj changes how the layer is drawn -- it falls back to the
+ // author's projection, or to nothing. Treating a transient 403 as absence
+ // would place features somewhere else with no error at all.
+ fetchMock.mockImplementation(respondingWith({ prj: 403 }));
+
+ const result = await acquireComponents("https://example.org/basins.shp");
+
+ expect(result.error.reason).toBe("component_status");
+ expect(result.error.component).toBe("prj");
+ });
+
+ it("still fails the layer on a 403 for .dbf", async () => {
+ fetchMock.mockImplementation(respondingWith({ dbf: 403 }));
+
+ const result = await acquireComponents("https://example.org/basins.shp");
+
+ expect(result.error.reason).toBe("component_status");
+ expect(result.error.component).toBe("dbf");
+ });
+
+ it("reports a 403 on the .shp itself as the layer failing", async () => {
+ fetchMock.mockImplementation(respondingWith({ shp: 403 }));
+
+ const result = await acquireComponents("https://example.org/basins.shp");
+
+ expect(result.error.stage).toBe("fetch");
+ expect(result.error.status).toBe(403);
+ });
+});
diff --git a/reactapp/__tests__/components/map/shapefile/siblings.test.js b/reactapp/__tests__/components/map/shapefile/siblings.test.js
index 7a90633d..7a259dad 100644
--- a/reactapp/__tests__/components/map/shapefile/siblings.test.js
+++ b/reactapp/__tests__/components/map/shapefile/siblings.test.js
@@ -115,9 +115,33 @@ describe("deriveSiblingUrls", () => {
expect(derived.dbf).toBe("https://example.org/shp.archive/basins.dbf");
});
- it("normalizes the derived extension to lower case from an upper-case source", () => {
+ it("spells the siblings the way the author spelled the source", () => {
+ // This previously asserted lower-cased siblings, describing it as
+ // normalization. It is not normalizable: the host decides, most are
+ // case-sensitive, and an author who wrote ".SHP" is describing a host that
+ // spells it that way. Lower-casing 404s every sibling.
const derived = deriveSiblingUrls("https://example.org/BASINS.SHP");
- expect(derived.dbf).toBe("https://example.org/BASINS.dbf");
+ expect(derived.dbf).toBe("https://example.org/BASINS.DBF");
+ expect(derived.prj).toBe("https://example.org/BASINS.PRJ");
+ expect(derived.cpg).toBe("https://example.org/BASINS.CPG");
+ });
+
+ it("requests the component the author named at exactly the url they gave", () => {
+ // The .shp is fetched from this table too, so rebuilding its name is how a
+ // lower-cased derivation 404s the one component that is required.
+ expect(deriveSiblingUrls("https://example.org/BASINS.SHP").shp).toBe(
+ "https://example.org/BASINS.SHP",
+ );
+ expect(deriveSiblingUrls("https://example.org/basins.shp").shp).toBe(
+ "https://example.org/basins.shp",
+ );
+ });
+
+ it("keeps a mixed-case extension as the author wrote it", () => {
+ // Only an all-upper extension implies an upper-case host convention.
+ const derived = deriveSiblingUrls("https://example.org/basins.Shp");
+ expect(derived.shp).toBe("https://example.org/basins.Shp");
+ expect(derived.dbf).toBe("https://example.org/basins.dbf");
});
it("handles a filename containing dots", () => {
diff --git a/reactapp/__tests__/components/map/shapefile/unzip.test.js b/reactapp/__tests__/components/map/shapefile/unzip.test.js
index 5ba977aa..6d00540c 100644
--- a/reactapp/__tests__/components/map/shapefile/unzip.test.js
+++ b/reactapp/__tests__/components/map/shapefile/unzip.test.js
@@ -191,3 +191,134 @@ describe("unzipShapefileComponents", () => {
expect(result.error.stage).toBe("parse");
});
});
+
+describe("unzipShapefileComponents — a declared size is a hint, not a bound", () => {
+ // The ceiling is the safety property that justifies fetching an arbitrary
+ // remote archive into a viewer's browser at all. It was applied to the size
+ // the local header declares, and fflate's inflater ignores that number
+ // entirely -- so a member that under-declared expanded without limit.
+ function lieAboutUncompressedSize(zipped, claimed) {
+ const lying = zipped.slice();
+ // Local file header: uncompressed size is a 32-bit LE field at offset 22.
+ new DataView(lying.buffer, lying.byteOffset, lying.byteLength).setUint32(
+ 22,
+ claimed,
+ true,
+ );
+ return lying;
+ }
+
+ it("refuses an oversized member that declares its real size", () => {
+ const honest = zipSync({ "basins.shp": new Uint8Array(40 * MB) });
+ const result = unzipShapefileComponents(honest, { maxBytes: 25 * MB });
+ expect(result.error.reason).toBe("too_large");
+ });
+
+ it("still refuses it when the header under-declares", () => {
+ const zipped = zipSync({ "basins.shp": new Uint8Array(40 * MB) });
+ const result = unzipShapefileComponents(
+ lieAboutUncompressedSize(zipped, 100),
+ { maxBytes: 25 * MB },
+ );
+
+ expect(result.error).toBeDefined();
+ expect(result.error.reason).toBe("too_large");
+ // And the refusal reflects what actually arrived, not the claim.
+ expect(result.error.observed).toBeGreaterThan(25 * MB);
+ });
+
+ it("does not double-charge an honest member", () => {
+ // The declared size is charged up front as a fast path; the arriving bytes
+ // must reconcile against it rather than adding to it, or a legitimate
+ // archive at half the ceiling would be refused.
+ const body = new Uint8Array(4 * MB).fill(7);
+ const result = unzipShapefileComponents(zipSync({ "basins.shp": body }), {
+ maxBytes: 6 * MB,
+ });
+ expect(result.error).toBeUndefined();
+ expect(result.components.shp).toHaveLength(4 * MB);
+ });
+});
+
+describe("unzipShapefileComponents — selecting the shapefile's own parts", () => {
+ it("reads a shapefile zipped on macOS", () => {
+ // Finder writes an AppleDouble twin beside every file. "._basins.shp" ends
+ // in ".shp", so it was counted as a second shapefile and the archive was
+ // rejected as ambiguous -- telling the author to point at a single
+ // shapefile, which is what they had done.
+ const result = unzipShapefileComponents(
+ archive({
+ ...MINIMAL,
+ "__MACOSX/._basins.shp": bytes("APPLEDOUBLE"),
+ "__MACOSX/._basins.dbf": bytes("APPLEDOUBLE"),
+ }),
+ { maxBytes: MB },
+ );
+
+ expect(result.error).toBeUndefined();
+ expect(text(result.components.shp)).toBe("SHPBODY");
+ expect(text(result.components.dbf)).toBe("DBFBODY");
+ });
+
+ it("ignores a bare ._ twin outside a __MACOSX directory", () => {
+ const result = unzipShapefileComponents(
+ archive({ ...MINIMAL, "._basins.shp": bytes("APPLEDOUBLE") }),
+ { maxBytes: MB },
+ );
+ expect(result.error).toBeUndefined();
+ expect(text(result.components.shp)).toBe("SHPBODY");
+ });
+
+ it("does not let an unrelated .dbf become the attribute table", () => {
+ // Components were keyed by extension alone, so whichever .dbf appeared last
+ // in the archive won -- silently drawing the geometry with another
+ // dataset's attributes rather than failing.
+ const result = unzipShapefileComponents(
+ archive({ ...MINIMAL, "extra/other.dbf": bytes("WRONGDBF") }),
+ { maxBytes: MB },
+ );
+
+ expect(result.error).toBeUndefined();
+ expect(text(result.components.dbf)).toBe("DBFBODY");
+ });
+
+ it("does not let an unrelated .dbf win by appearing first either", () => {
+ const result = unzipShapefileComponents(
+ archive({ "extra/other.dbf": bytes("WRONGDBF"), ...MINIMAL }),
+ { maxBytes: MB },
+ );
+ expect(text(result.components.dbf)).toBe("DBFBODY");
+ });
+
+ it("takes the parts sharing the .shp's directory and stem", () => {
+ const result = unzipShapefileComponents(
+ archive({
+ "nested/basins.shp": bytes("SHPBODY"),
+ "nested/basins.dbf": bytes("DBFBODY"),
+ "nested/basins.cpg": bytes("UTF-8"),
+ "basins.dbf": bytes("WRONGDBF"),
+ }),
+ { maxBytes: MB },
+ );
+
+ expect(text(result.components.shp)).toBe("SHPBODY");
+ expect(text(result.components.dbf)).toBe("DBFBODY");
+ expect(text(result.components.cpg)).toBe("UTF-8");
+ });
+
+ it("still reports two genuinely different shapefiles as ambiguous", () => {
+ const result = unzipShapefileComponents(
+ archive({ ...MINIMAL, "gages.shp": bytes("OTHERSHP") }),
+ { maxBytes: MB },
+ );
+ expect(result.error.reason).toBe("ambiguous_archive");
+ });
+
+ it("ignores directory entries", () => {
+ const result = unzipShapefileComponents(
+ archive({ "layers/": bytes(""), ...MINIMAL }),
+ { maxBytes: MB },
+ );
+ expect(result.error).toBeUndefined();
+ });
+});
diff --git a/reactapp/__tests__/components/map/shapefileDispatch.test.js b/reactapp/__tests__/components/map/shapefileDispatch.test.js
index 90dd898c..baedabca 100644
--- a/reactapp/__tests__/components/map/shapefileDispatch.test.js
+++ b/reactapp/__tests__/components/map/shapefileDispatch.test.js
@@ -91,122 +91,31 @@ describe("queryLayerFeatures", () => {
});
});
-describe("getLayerAttributes — Shapefile", () => {
- it("returns the union of .dbf field names", async () => {
- const attributes = await getLayerAttributes({
- sourceProps: SOURCE_PROPS,
- layerName: "Basins",
- dashboard_uuid: "uuid",
- });
-
- expect(attributes.Basins.map((f) => f.name).sort()).toEqual([
- "AREASQKM",
- "HUC8",
- "NAME",
- "STATES",
- ]);
- // No alias source for a shapefile, so each field aliases to itself.
- expect(attributes.Basins.every((f) => f.alias === f.name)).toBe(true);
- });
-
- it("passes the author's projection through as the fallback", async () => {
- await getLayerAttributes({
- sourceProps: {
- ...SOURCE_PROPS,
- props: { ...SOURCE_PROPS.props, projection: "EPSG:5070" },
- },
- layerName: "Basins",
- });
-
- expect(interpretShapefile).toHaveBeenCalledWith(expect.anything(), {
- fallbackProjection: "EPSG:5070",
- });
- });
-
- it("returns an empty list rather than throwing when the source cannot be read", async () => {
- acquireComponents.mockResolvedValue({
- error: { stage: "fetch", reason: "unreachable", detail: "no host" },
- });
-
- const attributes = await getLayerAttributes({
- sourceProps: SOURCE_PROPS,
- layerName: "Basins",
- });
-
- expect(attributes).toEqual({ Basins: [] });
- });
-
- it("returns an empty list when interpretation fails", async () => {
- interpretShapefile.mockResolvedValue({
- error: { stage: "parse", reason: "missing_projection", detail: "no prj" },
- });
-
- const attributes = await getLayerAttributes({
- sourceProps: SOURCE_PROPS,
- layerName: "Basins",
- });
-
- expect(attributes).toEqual({ Basins: [] });
- });
-
- it("returns an empty list for geometry with no attributes", async () => {
- // Covers the no-.dbf case: the layer still renders and is styleable by
- // geometry-independent rules, but offers no fields.
- interpretShapefile.mockResolvedValue({
- featureCollection: {
- type: "FeatureCollection",
- features: [
- {
- type: "Feature",
- properties: {},
- geometry: { type: "Point", coordinates: [0, 0] },
- },
- ],
- },
- projectionCode: "EPSG:4326",
- });
-
- const attributes = await getLayerAttributes({
- sourceProps: SOURCE_PROPS,
- layerName: "Basins",
- });
-
- expect(attributes.Basins).toEqual([]);
- });
-});
-
-describe("getStyleFields — Shapefile", () => {
- it("returns the same field list attribute discovery does", async () => {
- // Registering in only one of the two discovery trees gives working fields in
- // one pane and an empty list in the other, so this asserts they agree.
- const styleFields = await getStyleFields({
- sourceProps: SOURCE_PROPS,
- layerProps: { name: "Basins" },
- dashboard_uuid: "uuid",
- });
- const attributes = await getLayerAttributes({
- sourceProps: SOURCE_PROPS,
- layerName: "Basins",
- dashboard_uuid: "uuid",
- });
-
- expect(styleFields.sort()).toEqual(
- attributes.Basins.map((f) => f.name).sort(),
- );
+describe("shapefile field discovery is not wired into the generic dispatch", () => {
+ // These functions had Shapefile arms with direct unit tests, and both panes
+ // return before ever reaching them -- so the suite reported confidence in a
+ // path production never took. What actually needs asserting is the
+ // short-circuit, which is what makes those arms unnecessary.
+ it("getStyleFields does not treat Shapefile as a delegating source", async () => {
+ await expect(
+ getStyleFields({
+ sourceProps: { type: "Shapefile", props: { url: "https://x/b.zip" } },
+ layerProps: { name: "Basins" },
+ }),
+ ).resolves.toEqual([]);
});
- it("returns an empty list rather than throwing when the source cannot be read", async () => {
- acquireComponents.mockResolvedValue({ cancelled: true });
-
- const fields = await getStyleFields({
- sourceProps: SOURCE_PROPS,
- layerProps: { name: "Basins" },
- });
-
- expect(fields).toEqual([]);
+ it("getLayerAttributes refuses a Shapefile rather than reading one", async () => {
+ // Reaching this would download and parse the whole archive; the author
+ // triggers that explicitly from the Source tab instead.
+ await expect(
+ getLayerAttributes({
+ sourceProps: { type: "Shapefile", props: { url: "https://x/b.zip" } },
+ layerName: "Basins",
+ }),
+ ).rejects.toThrow(/not currently configured/);
});
});
-
describe("layerPropertiesOptions help text", () => {
it("names Shapefile among the types clickTolerance applies to", () => {
// This registry drives the editor's Layer Properties table, so the text is
diff --git a/reactapp/__tests__/components/map/shapefileSource.test.js b/reactapp/__tests__/components/map/shapefileSource.test.js
index 019de6f7..cef54b6e 100644
--- a/reactapp/__tests__/components/map/shapefileSource.test.js
+++ b/reactapp/__tests__/components/map/shapefileSource.test.js
@@ -290,3 +290,124 @@ describe("moduleLoader dispatch", () => {
).rejects.toThrow("ShapefileEmptySources");
});
});
+
+// A real tick boundary rather than a fixed count of microtasks: the pipeline
+// awaits two mocked stages, and counting `Promise.resolve()`s to match is the
+// kind of coupling that breaks the moment a stage is added.
+const flush = () => new Promise((resolve) => setTimeout(resolve, 0));
+
+describe("loadShapefile — a run that throws", () => {
+ it("reports an error instead of reporting 'loading' forever", async () => {
+ // Both pipeline stages report failures as return values, so nothing throws
+ // by design. A dynamic import still can, when a deploy invalidates the
+ // chunk a stale tab asks for -- and OpenLayers calls the loader without a
+ // catch of its own, so the rejection escaped and the layer sat on
+ // "loading" with no error shown and no retry offered.
+ interpretShapefile.mockRejectedValue(new Error("Loading chunk 283 failed"));
+ const source = loadShapefile(config(), "EPSG:3857");
+ const controller = source.get("shapefileController");
+
+ drive(source);
+ await flush();
+
+ expect(controller.getStatus()).toBe("error");
+ expect(controller.getError().detail).toMatch(/Loading chunk 283 failed/);
+ });
+
+ it("offers a retry for it, since a transient import failure clears", async () => {
+ const { isRetryable, errorKindFor } = require("components/map/layerStatus");
+ interpretShapefile.mockRejectedValue(new Error("network"));
+ const source = loadShapefile(config(), "EPSG:3857");
+ const controller = source.get("shapefileController");
+
+ drive(source);
+ await flush();
+
+ expect(isRetryable(errorKindFor(controller.getError()))).toBe(true);
+ });
+});
+
+describe("loadShapefile — a run that is no longer current", () => {
+ function deferred() {
+ let settle;
+ const promise = new Promise((resolve) => {
+ settle = resolve;
+ });
+ return { promise, settle };
+ }
+
+ it("adds no features and writes no status once aborted", async () => {
+ // Aborting stops the fetch, but the parse after it is CPU-bound and runs to
+ // completion regardless. Status is kept per layer name, so a late success
+ // from a discarded source lands under whichever source owns that name now.
+ const parse = deferred();
+ interpretShapefile.mockReturnValue(parse.promise);
+ const source = loadShapefile(config(), "EPSG:3857");
+ const controller = source.get("shapefileController");
+
+ drive(source);
+ await flush();
+
+ controller.abort("removed");
+ parse.settle({
+ featureCollection: COLLECTION,
+ projectionCode: "EPSG:4326",
+ });
+ await flush();
+
+ expect(source.getFeatures()).toHaveLength(0);
+ expect(controller.getStatus()).toBe("idle");
+ });
+
+ it("does not let a superseded run overwrite a newer run's outcome", async () => {
+ // Nothing disables retry while a load runs, and refresh() exists to force
+ // the loader to run again -- so two runs overlap, and the first must not
+ // report anything when it finally finishes.
+ const first = deferred();
+ interpretShapefile.mockReturnValueOnce(first.promise);
+ const source = loadShapefile(config(), "EPSG:3857");
+ const controller = source.get("shapefileController");
+
+ drive(source);
+ await flush();
+ expect(controller.getStatus()).toBe("loading");
+
+ // In the browser, reset()'s refresh() makes the renderer re-invoke the
+ // loader on its next frame. There is no renderer here, so the second run is
+ // driven explicitly -- which is also what the existing suite does.
+ interpretShapefile.mockResolvedValueOnce({
+ error: { stage: "parse", reason: "unreadable_geometry", detail: "bad" },
+ });
+ controller.reset();
+ drive(source);
+ await flush();
+ expect(controller.getStatus()).toBe("error");
+
+ // Now the first run's parse lands. It must stay silent rather than
+ // replacing the live error with a success.
+ first.settle({
+ featureCollection: COLLECTION,
+ projectionCode: "EPSG:4326",
+ });
+ await flush();
+
+ expect(controller.getStatus()).toBe("error");
+ expect(controller.getError().detail).toBe("bad");
+ });
+
+ it("aborts the in-flight run when reset supersedes it", async () => {
+ let captured = null;
+ acquireComponents.mockImplementation((_url, { signal }) => {
+ captured = signal;
+ return new Promise(() => {});
+ });
+ const source = loadShapefile(config(), "EPSG:3857");
+
+ drive(source);
+ await flush();
+ expect(captured.aborted).toBe(false);
+
+ source.get("shapefileController").reset();
+ expect(captured.aborted).toBe(true);
+ });
+});
diff --git a/reactapp/components/map/ModuleLoader.js b/reactapp/components/map/ModuleLoader.js
index 70ec0a13..abfe6401 100644
--- a/reactapp/components/map/ModuleLoader.js
+++ b/reactapp/components/map/ModuleLoader.js
@@ -32,6 +32,7 @@ import {
} from "components/map/utilities";
import { acquireComponents } from "components/map/shapefile/acquire";
import { interpretShapefile } from "components/map/shapefile/index";
+import { CANCEL_REASON } from "components/map/layerStatus";
import {
buildGeoTIFFStyleColor,
buildCategoricalStyleColor,
@@ -579,7 +580,11 @@ export const loadShapefile = (config, mapProjection, getMapProjection) => {
const source = new VectorSource();
source.setLoader(async (extent, resolution, projection, success, onError) => {
- abortController = new AbortController();
+ // Held locally as well as on the closure. The closure slot is what `abort`
+ // and a second invocation write to, so comparing the two is how this run
+ // learns it is no longer the current one.
+ const controller = new AbortController();
+ abortController = controller;
status = "loading";
failure = null;
@@ -589,40 +594,66 @@ export const loadShapefile = (config, mapProjection, getMapProjection) => {
abortController = null;
};
- const acquired = await acquireComponents(url, {
- signal: abortController.signal,
- });
- if (acquired.cancelled) {
- finish("idle");
- onError?.();
- return;
- }
- if (acquired.error) {
- finish("error", acquired.error);
- onError?.();
- return;
- }
+ // Aborting stops the fetch, but the parse that follows it is CPU-bound and
+ // runs to completion regardless. A run that is no longer current must write
+ // nothing at all: its layer may already be gone, and because status is kept
+ // per layer *name*, a late success would land under whichever source owns
+ // that name now -- erasing a live error and leaving a blank layer that
+ // reports nothing. Staying silent is also why neither callback fires here:
+ // the events they raise are still wired to this dead source.
+ const superseded = () => abortController !== controller;
+
+ try {
+ const acquired = await acquireComponents(url, {
+ signal: controller.signal,
+ });
+ if (superseded()) return;
+ if (acquired.cancelled) {
+ finish("idle");
+ onError?.();
+ return;
+ }
+ if (acquired.error) {
+ finish("error", acquired.error);
+ onError?.();
+ return;
+ }
- const interpreted = await interpretShapefile(acquired.components, {
- fallbackProjection,
- });
- if (interpreted.error) {
- finish("error", interpreted.error);
+ const interpreted = await interpretShapefile(acquired.components, {
+ fallbackProjection,
+ });
+ if (superseded()) return;
+ if (interpreted.error) {
+ finish("error", interpreted.error);
+ onError?.();
+ return;
+ }
+
+ // Read against the view as it stands now, not as it stood when the fetch
+ // was issued.
+ const targetProjection =
+ getMapProjection?.() ?? projection?.getCode?.() ?? mapProjection;
+ const features = readFeatureCollection(
+ interpreted.featureCollection,
+ targetProjection,
+ );
+ source.addFeatures(features);
+ finish("ready");
+ success?.(features);
+ } catch (error) {
+ // Both stages above report failures as values, so nothing here throws by
+ // design. A dynamic import still can -- a deploy invalidates the chunk a
+ // stale tab asks for -- and OpenLayers calls the loader without a catch of
+ // its own, so an escaping rejection would leave the layer reporting
+ // "loading" forever, with no error shown and no retry offered.
+ if (superseded()) return;
+ finish("error", {
+ stage: "fetch",
+ reason: "unexpected",
+ detail: `The shapefile could not be loaded: ${error?.message ?? error}`,
+ });
onError?.();
- return;
}
-
- // Read against the view as it stands now, not as it stood when the fetch
- // was issued.
- const targetProjection =
- getMapProjection?.() ?? projection?.getCode?.() ?? mapProjection;
- const features = readFeatureCollection(
- interpreted.featureCollection,
- targetProjection,
- );
- source.addFeatures(features);
- finish("ready");
- success?.(features);
});
source.set("shapefileController", {
@@ -640,6 +671,14 @@ export const loadShapefile = (config, mapProjection, getMapProjection) => {
// renderer short-circuits its frame on an unchanged layer revision -- which
// is how a retry button ends up doing nothing while its test passes.
reset: () => {
+ // Abort first. Nothing disables the retry affordance while a load runs,
+ // and `refresh` exists to force the loader to run again, so two runs can
+ // otherwise overlap -- the second replacing the first's controller and
+ // leaving it uncancellable.
+ if (abortController) {
+ abortController.abort(CANCEL_REASON.SUPERSEDED);
+ abortController = null;
+ }
status = "idle";
failure = null;
source.refresh();
diff --git a/reactapp/components/map/shapefile/acquire.js b/reactapp/components/map/shapefile/acquire.js
index 110d55e0..86d4dc02 100644
--- a/reactapp/components/map/shapefile/acquire.js
+++ b/reactapp/components/map/shapefile/acquire.js
@@ -25,6 +25,13 @@ export const DEFAULT_MAX_BYTES = 25 * 1024 * 1024;
const FETCH_STAGE_CAUSES =
"The likely causes are missing cross-origin headers on the host, an unreachable host, a URL that no longer exists, or an expired signature on a signed URL.";
+// Components whose absence cannot change how the layer draws: the encoding
+// falls back to a sniff of the .dbf itself, and the record index is never read.
+// Hosts disagree on what a missing object is -- an S3 bucket without
+// ListBucket returns 403, not 404 -- so for these two, any client error means
+// "not published" rather than failing a layer over a file it did not need.
+const INCONSEQUENTIAL_COMPONENTS = ["cpg", "shx"];
+
function fetchFailure(reason, detail, extra = {}) {
return { error: { stage: "fetch", reason, detail, ...extra } };
}
@@ -118,22 +125,27 @@ async function acquireSiblings(url, signal, maxBytes) {
return fetched;
}
- if (fetched.status === 404) {
- // Absence is only meaningful for the optional components. What matters is
- // that absence and failure stay distinguishable: a transient 403 routed
- // into the "no projection supplied" fallback would render features at the
- // wrong location with no error at all.
+ if (fetched.status >= 400) {
+ // The .shp is required; there is nothing to draw without it.
if (extension === "shp") {
return fetchFailure(
"unreachable",
`No shapefile was found at ${derived.shp}. ${FETCH_STAGE_CAUSES}`,
- { status: 404 },
+ { status: fetched.status },
);
}
- continue;
- }
- if (fetched.status >= 400) {
+ // For the components that do change how the layer draws, absence and
+ // failure stay distinguishable: a transient 403 on a .prj routed into the
+ // "no projection supplied" fallback would render features at the wrong
+ // location with no error at all. Only a 404 is absence for those.
+ if (
+ fetched.status === 404 ||
+ INCONSEQUENTIAL_COMPONENTS.includes(extension)
+ ) {
+ continue;
+ }
+
return {
error: {
stage: "fetch",
diff --git a/reactapp/components/map/shapefile/siblings.js b/reactapp/components/map/shapefile/siblings.js
index d6a6ae94..94e64052 100644
--- a/reactapp/components/map/shapefile/siblings.js
+++ b/reactapp/components/map/shapefile/siblings.js
@@ -119,11 +119,24 @@ export function deriveSiblingUrls(shpUrl) {
const url = new URL(shpUrl);
const segments = url.pathname.split("/");
const last = segments[segments.length - 1];
- const stem = last.slice(0, last.lastIndexOf("."));
+ const dot = last.lastIndexOf(".");
+ const stem = last.slice(0, dot);
+ const supplied = last.slice(dot + 1);
+ // An author who wrote ".SHP" is describing a host that spells it that way,
+ // and a case-sensitive host is the normal case -- S3 among them. Lower-casing
+ // would 404 the siblings, and because the .shp is fetched from this table
+ // too, it would 404 the one required component as well.
+ const upperCased = supplied === supplied.toUpperCase();
return COMPONENT_EXTENSIONS.reduce((derived, extension) => {
+ // The component the author actually named is requested at exactly the URL
+ // they gave, rather than at one rebuilt from it.
+ if (extension === supplied.toLowerCase()) {
+ return { ...derived, [extension]: shpUrl };
+ }
const rebuilt = new URL(url.toString());
- rebuilt.pathname = [...segments.slice(0, -1), `${stem}.${extension}`].join(
+ const spelled = upperCased ? extension.toUpperCase() : extension;
+ rebuilt.pathname = [...segments.slice(0, -1), `${stem}.${spelled}`].join(
"/",
);
return { ...derived, [extension]: rebuilt.toString() };
diff --git a/reactapp/components/map/shapefile/unzip.js b/reactapp/components/map/shapefile/unzip.js
index ac8006c1..942e7e03 100644
--- a/reactapp/components/map/shapefile/unzip.js
+++ b/reactapp/components/map/shapefile/unzip.js
@@ -26,13 +26,32 @@ export function createByteBudget(maxBytes) {
}
function componentExtension(name) {
- const base = name.split("/").pop() ?? "";
+ // Directory entries carry no data, and a macOS-zipped archive ships an
+ // AppleDouble twin beside every real file. "__MACOSX/._basins.shp" ends in
+ // ".shp", so counting it makes a Finder-zipped shapefile -- the way most
+ // people produce one by hand -- look like an archive holding two shapefiles.
+ if (name.endsWith("/")) return null;
+ const segments = name.split("/");
+ const base = segments[segments.length - 1] ?? "";
+ if (segments.includes("__MACOSX") || base.startsWith("._")) return null;
const dot = base.lastIndexOf(".");
if (dot === -1) return null;
const extension = base.slice(dot + 1).toLowerCase();
return COMPONENT_EXTENSIONS.includes(extension) ? extension : null;
}
+// The directory and stem that a shapefile's parts share. Components are matched
+// on this rather than on extension alone: an archive may hold unrelated parts,
+// and keying only by extension lets whichever .dbf appears last in the archive
+// silently become the attribute table for the geometry.
+function memberIdentity(name) {
+ const cut = name.lastIndexOf("/");
+ const directory = cut === -1 ? "" : name.slice(0, cut);
+ const base = cut === -1 ? name : name.slice(cut + 1);
+ const dot = base.lastIndexOf(".");
+ return { directory, stem: dot === -1 ? base : base.slice(0, dot) };
+}
+
function tooLarge(budget) {
const mb = (bytes) => (bytes / (1024 * 1024)).toFixed(1);
return {
@@ -83,7 +102,7 @@ export function unzipShapefileComponents(buffer, { maxBytes }) {
};
}
- const components = {};
+ const members = [];
const shpMembers = [];
const budget = createByteBudget(maxBytes);
let failure = null;
@@ -99,16 +118,24 @@ export function unzipShapefileComponents(buffer, { maxBytes }) {
if (!extension || failure) return;
if (extension === "shp") shpMembers.push(file.name);
+ // The declared size refuses an oversized member before any of it is
+ // inflated, which is the whole point of reading it. But it is a claim made
+ // by the archive, not a fact: fflate's inflater ignores it, so a member that
+ // under-declares would otherwise expand without limit. It is charged up
+ // front as a fast path, then reconciled against the bytes that actually
+ // arrive -- so a lying header buys nothing.
const declared = file.originalSize;
+ let charged = 0;
if (Number.isFinite(declared) && declared > 0) {
if (!budget.add(declared)) {
failure = tooLarge(budget);
return;
}
+ charged = declared;
}
const chunks = [];
- const declaredKnown = Number.isFinite(declared) && declared > 0;
+ let received = 0;
file.ondata = (error, chunk, final) => {
if (error) {
failure = failure ?? {
@@ -120,14 +147,16 @@ export function unzipShapefileComponents(buffer, { maxBytes }) {
};
return;
}
- // Counted only when the header declared nothing, so a declared member is
- // not charged twice.
- if (!declaredKnown) {
- if (!budget.add(chunk.length)) {
+ received += chunk.length;
+ // Only the overshoot beyond what the header already paid for, so an
+ // honest member is not charged twice.
+ if (received > charged) {
+ if (!budget.add(received - charged)) {
failure = tooLarge(budget);
file.terminate?.();
return;
}
+ charged = received;
}
chunks.push(chunk);
if (final) {
@@ -137,7 +166,7 @@ export function unzipShapefileComponents(buffer, { maxBytes }) {
merged.set(part, offset);
return offset + part.length;
}, 0);
- components[extension] = merged;
+ members.push({ name: file.name, extension, bytes: merged });
}
};
@@ -180,7 +209,8 @@ export function unzipShapefileComponents(buffer, { maxBytes }) {
};
}
- if (!components.shp) {
+ const shpMember = members.find((member) => member.extension === "shp");
+ if (!shpMember) {
return {
error: {
stage: "parse",
@@ -190,5 +220,20 @@ export function unzipShapefileComponents(buffer, { maxBytes }) {
};
}
+ // Only the parts belonging to this shapefile. A part with a different stem or
+ // in a different directory is another dataset's, and attaching it would draw
+ // the geometry with the wrong attributes rather than fail.
+ const target = memberIdentity(shpMember.name);
+ const components = members.reduce((selected, member) => {
+ const identity = memberIdentity(member.name);
+ if (
+ identity.directory === target.directory &&
+ identity.stem === target.stem
+ ) {
+ return { ...selected, [member.extension]: member.bytes };
+ }
+ return selected;
+ }, {});
+
return { components };
}
diff --git a/reactapp/components/map/utilities.js b/reactapp/components/map/utilities.js
index 0564e3c2..60c8ef24 100644
--- a/reactapp/components/map/utilities.js
+++ b/reactapp/components/map/utilities.js
@@ -1,6 +1,4 @@
import PropTypes from "prop-types";
-import { acquireComponents } from "components/map/shapefile/acquire";
-import { interpretShapefile } from "components/map/shapefile/index";
import { convertXML } from "simple-xml-to-json";
import { transform } from "ol/proj";
import Feature from "ol/Feature";
@@ -1022,15 +1020,11 @@ export async function getStyleFields({
isDynamicMapLayer = false,
}) {
let fields = [];
- // Shapefile joins the delegating branch rather than getting a second
- // implementation. Attribute discovery and style-field discovery are otherwise
- // independent trees, and registering in only one gives working fields in one
- // pane and an empty list in the other.
- if (
- isDynamicMapLayer ||
- sourceProps.type === "PMTiles Vector" ||
- sourceProps.type === "Shapefile"
- ) {
+ // Shapefile is deliberately absent: both panes take their fields from the
+ // author-triggered read in the Source tab (see useShapefileDiscovery) and
+ // return before reaching this function, because a discovery pass here would
+ // download and parse the whole archive on every source-props change.
+ if (isDynamicMapLayer || sourceProps.type === "PMTiles Vector") {
const attributes = await getLayerAttributes({
sourceProps,
layerName: layerProps?.name ?? "",
@@ -1143,12 +1137,6 @@ export async function getLayerAttributes({
attributes = await getKMLLayerAttributes(sourceUrl, layerName);
} else if (sourceType === "PMTiles Vector") {
attributes = await getPMTilesVectorLayerAttributes(sourceUrl);
- } else if (sourceType === "Shapefile") {
- attributes = await getShapefileLayerAttributes(
- sourceUrl,
- sourceProps?.props?.projection,
- layerName,
- );
} else {
throw Error(`${sourceType} is not currently configured to be queried`);
}
@@ -1156,35 +1144,6 @@ export async function getLayerAttributes({
return attributes;
}
-// Field names come from the .dbf, which means reading the source. Acquisition is
-// cached against the resolved URL, so the style pane and the attributes pane
-// reading in turn cost one fetch between them.
-async function getShapefileLayerAttributes(
- sourceUrl,
- fallbackProjection,
- layerName,
-) {
- const acquired = await acquireComponents(sourceUrl);
- if (acquired.error || acquired.cancelled) return { [layerName]: [] };
-
- const interpreted = await interpretShapefile(acquired.components, {
- fallbackProjection,
- });
- if (interpreted.error) return { [layerName]: [] };
-
- const fieldNames = new Set(
- (interpreted.featureCollection.features ?? []).flatMap((feature) =>
- Object.keys(feature.properties ?? {}),
- ),
- );
- return {
- [layerName]: Array.from(fieldNames).map((field) => ({
- name: field,
- alias: field,
- })),
- };
-}
-
async function getPMTilesVectorLayerAttributes(sourceUrl) {
// Default to tile 0/0/0 if not specified, or allow passing tile coordinates as needed
const z = 0,
diff --git a/reactapp/components/modals/MapLayer/AttributesPane.js b/reactapp/components/modals/MapLayer/AttributesPane.js
index b2bf4d0c..e608e8f8 100644
--- a/reactapp/components/modals/MapLayer/AttributesPane.js
+++ b/reactapp/components/modals/MapLayer/AttributesPane.js
@@ -177,8 +177,17 @@ const AttributesPane = ({
queryLayerAttributes().then(applyLayerAttributes);
}
}
+ // shapefileDiscovery is a dependency because the read is triggered from the
+ // Source tab: an author who opens Attributes first, then goes back and reads
+ // the fields, would otherwise never see them here -- the transition to
+ // "ready" is not a sourceProps change. StylePane's effect already lists it.
// eslint-disable-next-line react-hooks/exhaustive-deps
- }, [tabKey, sourceProps]);
+ }, [
+ tabKey,
+ sourceProps,
+ shapefileDiscovery?.state,
+ shapefileDiscovery?.fields,
+ ]);
useEffect(() => {
if (!valuesEqual(previousAttributeProps.current, attributeProps)) {
From fdecc218c063632e169908c574a59f57d77ffe3d Mon Sep 17 00:00:00 2001
From: Corey Krewson
Date: Tue, 25 Aug 2026 15:59:13 -0700
Subject: [PATCH 16/18] fix(review): detach load listeners, honor projection
edits, retry transfer failures
The four follow-ups from the review's remaining queue.
Load listeners are now detached whenever a load is aborted. The loader already
declines to report anything once superseded, which closed one half of the ghost
-write path; this closes the other, so a source that outlives its layer has
nothing listening to it. It matters because status is kept per layer *name* and
a rebuilt layer reuses the name, which is what let a dead source report under a
live layer's.
Preservation now compares the whole source object rather than only its url.
`projection` is the only way to place a shapefile that carries no .prj, and
matching on url alone meant editing it preserved the layer and re-read nothing
-- the author changed the field, saved, and the map did not move. Comparing the
source also covers whatever props it gains next, and the component cache keeps
the resulting rebuild cheap when only a non-url prop changed.
Two failures that surface at the parse stage are really about the transfer: a
portal answering with an HTML error page under a success status, and an archive
whose bytes stopped arriving partway through. Both are transient conditions of
the host or the connection -- the most retryable things that can happen -- and
classifying them by the stage they were noticed at reported them as the author's
file being wrong and withheld the retry that would have fixed them. A truncated
archive also had the same reason as "this is not a zip at all", so it could not
be worded differently; it now has its own.
The editor's field read had no AbortSignal at all, so closing the editor or
retyping the url left a multi-megabyte fetch running with its result still bound
for state, and whichever of two overlapping reads settled last won regardless of
which url it was for. Each read now owns a controller, supersedes the previous
one, aborts on unmount, and checks it is still current after both the fetch and
the parse. A thrown failure is reported rather than leaving the pane on
"loading" with its read button disabled.
Each fix was mutation-checked. The first attempt at the parse-stage supersede
test passed with the guard removed -- its superseded read was parked in the
fetch, so the earlier guard caught it and the later one was never exercised. The
test now supersedes a read that has cleared the fetch and is sitting in the
parse, which is the only way to reach it. The equivalent guard in ModuleLoader
was already covered.
Co-Authored-By: Claude Opus 5 (1M context)
---
.../components/map/layerStatus.test.js | 17 +-
.../components/map/shapefile/unzip.test.js | 27 +++
.../map/shapefilePreservation.test.js | 83 +++++++++-
.../MapLayer/shapefileDiscovery.test.js | 156 ++++++++++++++++++
reactapp/components/map/Map.js | 42 ++++-
reactapp/components/map/layerStatus.js | 10 ++
reactapp/components/map/shapefile/unzip.js | 8 +-
.../modals/MapLayer/shapefileDiscovery.js | 50 +++++-
8 files changed, 377 insertions(+), 16 deletions(-)
diff --git a/reactapp/__tests__/components/map/layerStatus.test.js b/reactapp/__tests__/components/map/layerStatus.test.js
index 407a0581..af31d63b 100644
--- a/reactapp/__tests__/components/map/layerStatus.test.js
+++ b/reactapp/__tests__/components/map/layerStatus.test.js
@@ -39,13 +39,28 @@ describe("errorKindFor", () => {
[{ stage: "fetch", reason: "unsupported_scheme" }, ERROR_KIND.FETCH],
[{ stage: "fetch", reason: "component_status" }, ERROR_KIND.FETCH],
[{ stage: "parse", reason: "unreadable_archive" }, ERROR_KIND.PARSE],
- [{ stage: "parse", reason: "wrong_content_type" }, ERROR_KIND.PARSE],
[{ stage: "parse", reason: "unreadable_geometry" }, ERROR_KIND.PARSE],
[{ stage: "parse", reason: "ambiguous_archive" }, ERROR_KIND.PARSE],
])("maps %o to %s", (failure, expected) => {
expect(errorKindFor(failure)).toBe(expected);
});
+ it.each([
+ [{ stage: "parse", reason: "wrong_content_type" }],
+ [{ stage: "parse", reason: "incomplete_archive" }],
+ ])(
+ "treats %o as a transfer failure even though it surfaced at the parse stage",
+ (failure) => {
+ // A portal answering with an HTML error page under a 200, and an archive
+ // whose bytes stopped arriving, are conditions of the host and the
+ // connection -- the most retryable things that can happen. This case
+ // previously asserted PARSE, which withheld the retry that would have
+ // fixed it and told the author their file was wrong.
+ expect(errorKindFor(failure)).toBe(ERROR_KIND.FETCH);
+ expect(isRetryable(errorKindFor(failure))).toBe(true);
+ },
+ );
+
it("gives the size ceiling its own kind regardless of stage", () => {
// The pipeline reports this on the fetch stage, but a viewer must not be
// offered a retry for it.
diff --git a/reactapp/__tests__/components/map/shapefile/unzip.test.js b/reactapp/__tests__/components/map/shapefile/unzip.test.js
index 6d00540c..01b04a92 100644
--- a/reactapp/__tests__/components/map/shapefile/unzip.test.js
+++ b/reactapp/__tests__/components/map/shapefile/unzip.test.js
@@ -322,3 +322,30 @@ describe("unzipShapefileComponents — selecting the shapefile's own parts", ()
expect(result.error).toBeUndefined();
});
});
+
+describe("unzipShapefileComponents — a transfer that did not finish", () => {
+ it("distinguishes a truncated archive from a file that is not one", () => {
+ // A dropped connection is the most retryable failure there is, and it was
+ // reported with the same reason as "this is not a zip" -- permanent, no
+ // retry offered, and worded as though the author had picked the wrong URL.
+ // The body has to be big enough that the cut lands inside the .shp's own
+ // stream. MINIMAL's members are a few bytes each and .shp comes first, so a
+ // cut past it leaves a complete .shp with its optional siblings merely
+ // absent -- which is a success, correctly.
+ const body = new Uint8Array(200 * 1024);
+ for (let i = 0; i < body.length; i += 1) body[i] = (i * 31) % 251;
+ const full = zipSync({ "basins.shp": body, "basins.dbf": body });
+ const truncated = full.slice(0, Math.floor(full.length * 0.5));
+
+ const result = unzipShapefileComponents(truncated, { maxBytes: MB });
+
+ expect(result.error.reason).toBe("incomplete_archive");
+ expect(result.error.detail).toMatch(/did not arrive completely/);
+ });
+
+ it("still reports a payload that never was an archive as unreadable", () => {
+ const html = bytes("404");
+ const result = unzipShapefileComponents(html, { maxBytes: MB });
+ expect(result.error.reason).toBe("unreadable_archive");
+ });
+});
diff --git a/reactapp/__tests__/components/map/shapefilePreservation.test.js b/reactapp/__tests__/components/map/shapefilePreservation.test.js
index 44c21cff..28bb196c 100644
--- a/reactapp/__tests__/components/map/shapefilePreservation.test.js
+++ b/reactapp/__tests__/components/map/shapefilePreservation.test.js
@@ -37,13 +37,17 @@ const COLLECTION = {
function shapefileLayer({
url = "https://example.org/basins.zip",
+ projection,
style,
} = {}) {
return {
type: "VectorLayer",
props: {
name: "Basins",
- source: { type: "Shapefile", props: { url } },
+ source: {
+ type: "Shapefile",
+ props: { url, ...(projection === undefined ? {} : { projection }) },
+ },
},
...(style === undefined ? {} : { style }),
};
@@ -243,3 +247,80 @@ describe("shapefile load cancellation", () => {
});
});
});
+
+describe("shapefile preservation and the source's other props", () => {
+ it("loads again when the author changes the projection", async () => {
+ // `projection` is the only way to place a shapefile carrying no .prj.
+ // Preservation matched on the url alone, so editing it preserved the layer
+ // and re-read nothing -- the author changed the field, saved, and the map
+ // did not move.
+ await mount([shapefileLayer()]);
+ await drive();
+ expect(acquireComponents).toHaveBeenCalledTimes(1);
+ const original = shapefileLayers()[0];
+
+ setLayers([shapefileLayer({ projection: "EPSG:5070" })]);
+ await reconciled(() => expect(shapefileLayers()[0]).not.toBe(original));
+ await drive();
+
+ expect(acquireComponents.mock.calls.length).toBeGreaterThan(1);
+ expect(interpretShapefile).toHaveBeenLastCalledWith(expect.anything(), {
+ fallbackProjection: "EPSG:5070",
+ });
+ });
+
+ it("still preserves the layer when nothing about the source changed", async () => {
+ await mount([shapefileLayer({ projection: "EPSG:5070" })]);
+ await drive();
+ const original = shapefileLayers()[0];
+
+ setLayers([
+ shapefileLayer({ projection: "EPSG:5070" }),
+ otherLayer({ opacity: 0.4 }),
+ ]);
+ await reconciled(() =>
+ expect(layerNamed("Basemap").getOpacity()).toBeCloseTo(0.4),
+ );
+ await drive();
+
+ expect(acquireComponents).toHaveBeenCalledTimes(1);
+ expect(shapefileLayers()[0]).toBe(original);
+ });
+});
+
+describe("shapefile load listeners", () => {
+ it("stops listening to a removed layer's source", async () => {
+ await mount([shapefileLayer()]);
+ await drive();
+ const source = shapefileLayers()[0].getSource();
+ expect(source.getListeners("featuresloadend")?.length ?? 0).toBeGreaterThan(
+ 0,
+ );
+
+ setLayers([otherLayer()]);
+ await reconciled(() => expect(shapefileLayers()).toHaveLength(0));
+
+ // Load status is kept per layer *name*, and a rebuilt layer reuses the
+ // name -- so a dead source still being listened to can report under a name
+ // a different source now owns.
+ expect(source.getListeners("featuresloadend")?.length ?? 0).toBe(0);
+ expect(source.getListeners("featuresloaderror")?.length ?? 0).toBe(0);
+ });
+
+ it("listens to the replacement after a rebuild", async () => {
+ await mount([shapefileLayer()]);
+ await drive();
+ const original = shapefileLayers()[0].getSource();
+
+ setLayers([shapefileLayer({ url: "https://example.org/gages.zip" })]);
+ await reconciled(() =>
+ expect(shapefileLayers()[0].getSource()).not.toBe(original),
+ );
+
+ expect(original.getListeners("featuresloadend")?.length ?? 0).toBe(0);
+ expect(
+ shapefileLayers()[0].getSource().getListeners("featuresloadend")
+ ?.length ?? 0,
+ ).toBeGreaterThan(0);
+ });
+});
diff --git a/reactapp/__tests__/components/modals/MapLayer/shapefileDiscovery.test.js b/reactapp/__tests__/components/modals/MapLayer/shapefileDiscovery.test.js
index adafbc77..27e48d3d 100644
--- a/reactapp/__tests__/components/modals/MapLayer/shapefileDiscovery.test.js
+++ b/reactapp/__tests__/components/modals/MapLayer/shapefileDiscovery.test.js
@@ -306,3 +306,159 @@ describe("useShapefileDiscovery", () => {
expect(result.current.failure).toBeNull();
});
});
+
+describe("useShapefileDiscovery — reads that overlap or are abandoned", () => {
+ it("passes an abort signal so a read can be cancelled at all", async () => {
+ // There was no signal here, so a multi-megabyte read kept running after the
+ // author moved on, with its result still bound for state.
+ let captured;
+ acquireComponents.mockImplementation((_url, options) => {
+ captured = options?.signal;
+ return Promise.resolve({ components: { shp: new Uint8Array() } });
+ });
+ const { result } = setup();
+
+ await act(async () => {
+ await result.current.load();
+ });
+
+ expect(captured).toBeInstanceOf(AbortSignal);
+ });
+
+ it("aborts the previous read when a new one starts", async () => {
+ const signals = [];
+ acquireComponents.mockImplementation((_url, options) => {
+ signals.push(options.signal);
+ return new Promise(() => {});
+ });
+ const { result } = setup();
+
+ act(() => {
+ result.current.load();
+ });
+ act(() => {
+ result.current.load();
+ });
+
+ expect(signals).toHaveLength(2);
+ expect(signals[0].aborted).toBe(true);
+ expect(signals[1].aborted).toBe(false);
+ });
+
+ it("does not let a superseded read report its own fields", async () => {
+ // Whichever read settled last was winning, regardless of which url it was
+ // for -- so a slow read of the previous url could overwrite a fast read of
+ // the current one.
+ let settleFirst;
+ acquireComponents
+ .mockImplementationOnce(
+ () =>
+ new Promise((resolve) => {
+ settleFirst = resolve;
+ }),
+ )
+ .mockResolvedValueOnce({ components: { shp: new Uint8Array() } });
+ // Ordered by completion, not by which read started first: read one is
+ // parked in acquireComponents, so read two reaches interpretShapefile first
+ // and consumes the leading mock.
+ interpretShapefile
+ .mockResolvedValueOnce(collection([{ FRESH: 1 }]))
+ .mockResolvedValueOnce(collection([{ STALE: 1 }]));
+
+ const { result } = setup();
+ act(() => {
+ result.current.load();
+ });
+ await act(async () => {
+ await result.current.load();
+ });
+ expect(result.current.fields).toEqual(["FRESH"]);
+
+ // The first read lands late. It must stay silent.
+ await act(async () => {
+ settleFirst({ components: { shp: new Uint8Array() } });
+ await Promise.resolve();
+ });
+
+ expect(result.current.fields).toEqual(["FRESH"]);
+ });
+
+ it("aborts an in-flight read on unmount", async () => {
+ let captured;
+ acquireComponents.mockImplementation((_url, options) => {
+ captured = options.signal;
+ return new Promise(() => {});
+ });
+ const { result, unmount } = setup();
+
+ act(() => {
+ result.current.load();
+ });
+ expect(captured.aborted).toBe(false);
+
+ unmount();
+ expect(captured.aborted).toBe(true);
+ });
+
+ it("reports a thrown failure rather than staying on loading", async () => {
+ // The pipeline reports failures as values, but its dynamic imports can
+ // reject -- and the pane stayed on "loading" with the read button disabled
+ // and nothing said.
+ interpretShapefile.mockRejectedValue(new Error("Loading chunk 44 failed"));
+ const { result } = setup();
+
+ await act(async () => {
+ await result.current.load();
+ });
+
+ expect(result.current.state).toBe("error");
+ expect(result.current.failure.detail).toMatch(/Loading chunk 44 failed/);
+ });
+});
+
+describe("useShapefileDiscovery — superseded during the parse", () => {
+ it("does not report a read that was superseded after its fetch finished", async () => {
+ // The guard after the fetch is not enough on its own: a read can clear it,
+ // then sit in the parse -- which takes no signal and cannot be cancelled --
+ // while the author starts another. Only a second check, after the parse,
+ // stops the older read from reporting.
+ let settleParse;
+ acquireComponents.mockResolvedValue({
+ components: { shp: new Uint8Array() },
+ });
+ interpretShapefile
+ .mockImplementationOnce(
+ () =>
+ new Promise((resolve) => {
+ settleParse = resolve;
+ }),
+ )
+ .mockResolvedValueOnce(collection([{ FRESH: 1 }]));
+
+ const { result } = setup();
+
+ // Read one clears the post-fetch guard and parks in the parse.
+ act(() => {
+ result.current.load();
+ });
+ await act(async () => {
+ await Promise.resolve();
+ });
+ expect(interpretShapefile).toHaveBeenCalledTimes(1);
+
+ // Read two supersedes it and finishes.
+ await act(async () => {
+ await result.current.load();
+ });
+ expect(result.current.fields).toEqual(["FRESH"]);
+
+ // Read one's parse lands last. It must not report.
+ await act(async () => {
+ settleParse(collection([{ STALE: 1 }]));
+ await Promise.resolve();
+ });
+
+ expect(result.current.fields).toEqual(["FRESH"]);
+ expect(result.current.state).toBe("ready");
+ });
+});
diff --git a/reactapp/components/map/Map.js b/reactapp/components/map/Map.js
index 58438174..63ed24ec 100644
--- a/reactapp/components/map/Map.js
+++ b/reactapp/components/map/Map.js
@@ -31,6 +31,7 @@ import PropTypes from "prop-types";
import { useMapContext } from "components/contexts/MapContext";
import { fromExtent } from "ol/geom/Polygon";
import { transformExtent } from "ol/proj";
+import { unByKey } from "ol/Observable";
import { VariableInputsContext } from "components/contexts/Contexts";
import GeoJSON from "ol/format/GeoJSON";
import { valuesEqual } from "components/modals/utilities";
@@ -113,15 +114,33 @@ function watchShapefileLoad(olLayer, layerName, setStatus) {
}));
};
- source.on("featuresloadstart", sync);
- source.on("featuresloadend", sync);
- source.on("featuresloaderror", sync);
+ // Kept on the layer so teardown can find them. The status map is keyed by
+ // layer name, and a rebuilt layer reuses the name -- so a source that outlives
+ // its layer with these still attached is able to write under a name a
+ // different source now owns.
+ olLayer.set("shapefileLoadKeys", [
+ source.on("featuresloadstart", sync),
+ source.on("featuresloadend", sync),
+ source.on("featuresloaderror", sync),
+ ]);
}
-// Stop an in-flight shapefile load. Called when the layer is going away, so the
-// fetch and decompression do not keep running for a layer nobody will see.
+// Detach a shapefile source's load listeners. Paired with every abort: the
+// loader already declines to report anything once superseded, and this closes
+// the other half by making sure nothing is listening if it ever did.
+function detachShapefileLoad(olLayer) {
+ const keys = olLayer?.get?.("shapefileLoadKeys");
+ if (!keys) return;
+ unByKey(keys);
+ olLayer.unset("shapefileLoadKeys");
+}
+
+// Stop an in-flight shapefile load and stop listening to it. Called when the
+// layer is going away, so the fetch and decompression do not keep running for a
+// layer nobody will see.
function abortShapefileLoad(olLayer, reason) {
olLayer?.getSource?.()?.get?.("shapefileController")?.abort?.(reason);
+ detachShapefileLoad(olLayer);
}
const MapComponent = ({
@@ -441,12 +460,21 @@ const MapComponent = ({
currentLayer?.props?.source?.type === "Shapefile" &&
currentLayer.type === "VectorLayer"
) {
+ // The whole source is compared, not just the url. `projection` is
+ // the only way to place a shapefile that carries no .prj, and
+ // matching on url alone made editing it a silent no-op -- the layer
+ // was preserved, so nothing re-read or re-interpreted it. Comparing
+ // the source object also covers whatever props it gains next, and
+ // the component cache keeps the resulting rebuild cheap when only a
+ // non-url prop changed.
const incoming = (layers ?? []).find(
(candidate) =>
candidate?.props?.source?.type === "Shapefile" &&
candidate?.props?.name === currentLayer.props.name &&
- candidate?.props?.source?.props?.url ===
- currentLayer.props.source?.props?.url,
+ valuesEqual(
+ candidate?.props?.source,
+ currentLayer.props.source,
+ ),
);
if (incoming) {
layersToKeep.push(incoming.props.name);
diff --git a/reactapp/components/map/layerStatus.js b/reactapp/components/map/layerStatus.js
index acbe6ed2..240c0115 100644
--- a/reactapp/components/map/layerStatus.js
+++ b/reactapp/components/map/layerStatus.js
@@ -45,6 +45,15 @@ export const ERROR_KIND = {
/** Failure kinds where re-running the same request could plausibly succeed. */
const RETRYABLE = [ERROR_KIND.FETCH];
+// Failures that surface at the parse stage but are really about the transfer.
+// A portal that answers with an HTML error page under a success status, and an
+// archive whose bytes stopped arriving partway through, are both transient
+// conditions of the host or the connection -- the most retryable things that
+// can happen. Classifying them by the stage they were noticed at reported them
+// as the author's file being wrong and withheld the retry that would have
+// fixed them.
+const TRANSFER_REASONS = ["wrong_content_type", "incomplete_archive"];
+
/**
* Whether a retry affordance should be offered for a failure of this kind.
*
@@ -73,5 +82,6 @@ export function errorKindFor(failure) {
) {
return ERROR_KIND.PROJECTION;
}
+ if (TRANSFER_REASONS.includes(failure?.reason)) return ERROR_KIND.FETCH;
return failure?.stage === "parse" ? ERROR_KIND.PARSE : ERROR_KIND.FETCH;
}
diff --git a/reactapp/components/map/shapefile/unzip.js b/reactapp/components/map/shapefile/unzip.js
index 942e7e03..b93dc1bb 100644
--- a/reactapp/components/map/shapefile/unzip.js
+++ b/reactapp/components/map/shapefile/unzip.js
@@ -186,11 +186,15 @@ export function unzipShapefileComponents(buffer, { maxBytes }) {
try {
unzip.push(buffer, true);
} catch (error) {
+ // The magic-number check above already passed, so this did begin as a zip
+ // and stopped being readable partway through -- which is what a dropped
+ // connection looks like, not a file the author chose wrongly. Reported as a
+ // transfer failure so a retry is offered rather than blaming the URL.
return {
error: {
stage: "parse",
- reason: "unreadable_archive",
- detail: `The source could not be read as a zip archive: ${error.message}`,
+ reason: "incomplete_archive",
+ detail: `The archive did not arrive completely: ${error.message}. A transfer cut short is the usual cause.`,
},
};
}
diff --git a/reactapp/components/modals/MapLayer/shapefileDiscovery.js b/reactapp/components/modals/MapLayer/shapefileDiscovery.js
index 88085010..8af2e82b 100644
--- a/reactapp/components/modals/MapLayer/shapefileDiscovery.js
+++ b/reactapp/components/modals/MapLayer/shapefileDiscovery.js
@@ -1,8 +1,12 @@
-import { useCallback, useMemo, useRef, useState } from "react";
+import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { updateObjectWithVariableInputs } from "components/visualizations/utilities";
import { acquireComponents } from "components/map/shapefile/acquire";
import { interpretShapefile } from "components/map/shapefile/index";
-import { errorKindFor, ERROR_KIND } from "components/map/layerStatus";
+import {
+ errorKindFor,
+ ERROR_KIND,
+ CANCEL_REASON,
+} from "components/map/layerStatus";
// How long a pending read may run before the message escalates. A shapefile can
// take many seconds legitimately, and an indicator that never changes reads as a
@@ -113,6 +117,15 @@ export function useShapefileDiscovery({
const [fields, setFields] = useState([]);
const [failure, setFailure] = useState(null);
const byUrl = useRef(new Map());
+ const inFlight = useRef(null);
+
+ // A read the author has moved off is abandoned rather than raced. It had no
+ // signal at all before, so closing the editor or retyping the url left a
+ // multi-megabyte fetch running with its result still bound for state.
+ useEffect(
+ () => () => inFlight.current?.controller.abort(CANCEL_REASON.UNMOUNT),
+ [],
+ );
const isShapefile = sourceProps?.type === "Shapefile";
const resolvedUrl = isShapefile
@@ -134,10 +147,20 @@ export function useShapefileDiscovery({
return;
}
+ inFlight.current?.controller.abort(CANCEL_REASON.SUPERSEDED);
+ const controller = new AbortController();
+ inFlight.current = { controller };
+ // Only the read that is still current may write. Two reads overlap whenever
+ // the author clicks twice or edits the url mid-read, and the one that
+ // settles last was winning regardless of which url it was for.
+ const current = () => inFlight.current?.controller === controller;
+
setState("loading");
setSlow(false);
setFailure(null);
- const slowTimer = setTimeout(() => setSlow(true), SLOW_LOAD_MS);
+ const slowTimer = setTimeout(() => {
+ if (current()) setSlow(true);
+ }, SLOW_LOAD_MS);
const report = (error) => {
const kind = errorKindFor(error);
@@ -152,7 +175,10 @@ export function useShapefileDiscovery({
};
try {
- const acquired = await acquireComponents(resolvedUrl);
+ const acquired = await acquireComponents(resolvedUrl, {
+ signal: controller.signal,
+ });
+ if (!current()) return;
if (acquired.cancelled) {
setState("idle");
return;
@@ -165,6 +191,7 @@ export function useShapefileDiscovery({
const interpreted = await interpretShapefile(acquired.components, {
fallbackProjection: sourceProps?.props?.projection,
});
+ if (!current()) return;
if (interpreted.error) {
report(interpreted.error);
return;
@@ -180,9 +207,22 @@ export function useShapefileDiscovery({
byUrl.current.set(resolvedUrl, discovered);
setFields(discovered);
setState("ready");
+ } catch (error) {
+ // The pipeline reports failures as values, but its dynamic imports can
+ // still reject -- and without this the pane stays on "loading" with the
+ // read button disabled and nothing said.
+ if (!current()) return;
+ report({
+ stage: "fetch",
+ reason: "unexpected",
+ detail: `The shapefile could not be read: ${error?.message ?? error}`,
+ });
} finally {
clearTimeout(slowTimer);
- setSlow(false);
+ if (current()) {
+ setSlow(false);
+ inFlight.current = null;
+ }
}
}, [resolvedUrl, sourceProps?.props?.projection]);
From c89af0bb476e115a342c7464a80a44c5fab8230a Mon Sep 17 00:00:00 2001
From: Corey Krewson
Date: Wed, 26 Aug 2026 09:45:34 -0700
Subject: [PATCH 17/18] Stop fetching .shx, register the client-side sources,
load proj4 on demand
Three cleanups surfaced by the review, none of them defects.
The .shx is the record index and nothing reads it -- the parser is handed the
.shp and .dbf and walks them sequentially. Fetching it cost a round-trip on
every unzipped shapefile, and in the archive path it was inflated into memory
and charged against the 25 MB ceiling, which at four bytes per record is real
headroom on a large file. Dropped from the component set; the archive path
simply stops extracting it.
LayerConfigurationBuilder refused four source types the layer editor offers.
Shapefile, GeoTIFF, Zarr and Static Image all reached the frontend's source
registry without being added to the Python builder, so a plugin author following
the path the docs call recommended got a ValueError naming a list the type was
missing from. All four are now registered with the layer class getLayerType
writes, plus their source properties, so a plugin-built layer matches what the
editor produces. Hand-building the raw dict always worked, which is why nothing
caught it.
proj4, wkt-parser and the definition table cost ~150 KiB in the main bundle plus
a quadratic registration pass, and most dashboards have no layer needing any of
it. Nothing imports the module statically now. `isNativelyResolvable` moved to
its own dependency-free module, since the answer is a property of the code and
needed neither proj4 nor the table -- that static import was the whole reason
the machinery reached the bundle. The map awaits the module only when a layer
could carry or name a non-native CRS, and the shapefile pipeline loads it inside
its own async read alongside the parser's dynamic imports.
The trigger is deliberately broad: a source reading its CRS out of its own data
-- GeoTIFF or Zarr from the file, a shapefile from its .prj -- has no code in
its config to inspect, so the type alone requires it. Awaited before any layer
is constructed, because importing the module is what registers the table codes
and a layer that merely names one needs the definition on hand when OpenLayers
resolves it. Verified safe to defer: the view projection is never a table code
-- it starts at EPSG:3857 and the auto-fit adopts only natively-resolvable codes
-- so nothing resolves a projection before that point.
Measured: the feature's cost to main.js drops from +156 KiB to +24 KiB, with
131 KiB moved into chunks that load only when something needs them.
Co-Authored-By: Claude Opus 5 (1M context)
---
docs/source/maps/source_tab.rst | 2 +-
.../components/map/shapefile/acquire.test.js | 6 +-
.../components/map/shapefile/siblings.test.js | 2 +-
.../components/map/shapefile/unzip.test.js | 8 ++-
reactapp/components/map/Map.js | 35 +++++++++++-
reactapp/components/map/projectionCodes.js | 34 +++++++++++
reactapp/components/map/projections.js | 36 ++----------
reactapp/components/map/shapefile/acquire.js | 10 ++--
reactapp/components/map/shapefile/index.js | 19 +++++--
reactapp/components/map/shapefile/siblings.js | 18 ++++--
tethysapp/tethysdash/plugin_helpers.py | 56 +++++++++++++++++++
.../tests/unit_tests/test_plugin_helpers.py | 39 +++++++++++++
12 files changed, 208 insertions(+), 57 deletions(-)
create mode 100644 reactapp/components/map/projectionCodes.js
diff --git a/docs/source/maps/source_tab.rst b/docs/source/maps/source_tab.rst
index 212c4e32..54abc5e5 100644
--- a/docs/source/maps/source_tab.rst
+++ b/docs/source/maps/source_tab.rst
@@ -106,7 +106,7 @@ The Shapefile source draws an ESRI Shapefile that is already published on the we
Two forms are accepted, and the same **url** field takes either:
- A **zipped shapefile**, whose path ends in ``.zip``.
-- The **.shp component** of an unzipped set. The sibling ``.dbf``, ``.prj``, ``.shx`` and ``.cpg`` files are requested from the same path automatically, so only the ``.shp`` URL is entered. Any query string on the URL is preserved on each request, which keeps signed links working.
+- The **.shp component** of an unzipped set. The sibling ``.dbf``, ``.prj`` and ``.cpg`` files are requested from the same path automatically, so only the ``.shp`` URL is entered. The ``.shx`` index is not requested -- it is not needed to draw the layer. Any query string on the URL is preserved on each request, which keeps signed links working.
A portal **download endpoint** counts as the first form even when its path carries no ``.zip`` — ArcGIS Hub and similar services serve shapefiles from a URL ending in something like ``/downloads/data?format=shp``. Paste it as-is.
diff --git a/reactapp/__tests__/components/map/shapefile/acquire.test.js b/reactapp/__tests__/components/map/shapefile/acquire.test.js
index e569a68b..f8bb830b 100644
--- a/reactapp/__tests__/components/map/shapefile/acquire.test.js
+++ b/reactapp/__tests__/components/map/shapefile/acquire.test.js
@@ -97,17 +97,17 @@ describe("acquireComponents — validation happens before any request", () => {
});
describe("acquireComponents — archive form", () => {
- it("returns the four components from one request", async () => {
+ it("returns the components from one request", async () => {
fetchMock.mockResolvedValue(respond({ body: ARCHIVE }));
const result = await acquireComponents("https://example.org/basins.zip");
expect(result.error).toBeUndefined();
+ // No .shx: it is the record index and nothing reads it.
expect(Object.keys(result.components).sort()).toEqual([
"dbf",
"prj",
"shp",
- "shx",
]);
expect(fetchMock).toHaveBeenCalledTimes(1);
});
@@ -189,14 +189,12 @@ describe("acquireComponents — sibling form", () => {
"dbf",
"prj",
"shp",
- "shx",
]);
const requested = fetchMock.mock.calls.map(([u]) => u);
expect(requested).toEqual([
"https://example.org/basins.shp",
"https://example.org/basins.dbf",
"https://example.org/basins.prj",
- "https://example.org/basins.shx",
"https://example.org/basins.cpg",
]);
});
diff --git a/reactapp/__tests__/components/map/shapefile/siblings.test.js b/reactapp/__tests__/components/map/shapefile/siblings.test.js
index 7a259dad..0c1e9af9 100644
--- a/reactapp/__tests__/components/map/shapefile/siblings.test.js
+++ b/reactapp/__tests__/components/map/shapefile/siblings.test.js
@@ -88,7 +88,6 @@ describe("deriveSiblingUrls", () => {
shp: "https://example.org/data/basins.shp",
dbf: "https://example.org/data/basins.dbf",
prj: "https://example.org/data/basins.prj",
- shx: "https://example.org/data/basins.shx",
cpg: "https://example.org/data/basins.cpg",
});
});
@@ -124,6 +123,7 @@ describe("deriveSiblingUrls", () => {
expect(derived.dbf).toBe("https://example.org/BASINS.DBF");
expect(derived.prj).toBe("https://example.org/BASINS.PRJ");
expect(derived.cpg).toBe("https://example.org/BASINS.CPG");
+ expect(derived.shx).toBeUndefined();
});
it("requests the component the author named at exactly the url they gave", () => {
diff --git a/reactapp/__tests__/components/map/shapefile/unzip.test.js b/reactapp/__tests__/components/map/shapefile/unzip.test.js
index 01b04a92..c9547005 100644
--- a/reactapp/__tests__/components/map/shapefile/unzip.test.js
+++ b/reactapp/__tests__/components/map/shapefile/unzip.test.js
@@ -54,16 +54,17 @@ describe("createByteBudget", () => {
});
describe("unzipShapefileComponents", () => {
- it("extracts the four components and decodes nothing", () => {
+ it("extracts the components it reads and decodes nothing", () => {
const result = unzipShapefileComponents(archive(MINIMAL), {
maxBytes: 10 * MB,
});
expect(result.error).toBeUndefined();
+ // The .shx is present in the archive and deliberately not extracted: it is
+ // the record index and nothing here reads it.
expect(Object.keys(result.components).sort()).toEqual([
"dbf",
"prj",
"shp",
- "shx",
]);
// Buffers come back raw; decoding the .prj is the interpretation step's job.
expect(result.components.shp).toBeInstanceOf(Uint8Array);
@@ -108,11 +109,12 @@ describe("unzipShapefileComponents", () => {
{ maxBytes: 10 * MB },
);
expect(result.error).toBeUndefined();
+ // The .shx is present in the archive and deliberately not extracted: it is
+ // the record index and nothing here reads it.
expect(Object.keys(result.components).sort()).toEqual([
"dbf",
"prj",
"shp",
- "shx",
]);
});
diff --git a/reactapp/components/map/Map.js b/reactapp/components/map/Map.js
index 63ed24ec..6c0b8243 100644
--- a/reactapp/components/map/Map.js
+++ b/reactapp/components/map/Map.js
@@ -9,7 +9,7 @@ import moduleLoader, {
// place before the layer effect below constructs a single source -- which
// matters, because layers are constructed concurrently and a registration that
// waited on anything async would race them.
-import { isNativelyResolvable } from "components/map/projections";
+import { isNativelyResolvable } from "components/map/projectionCodes";
import { CANCEL_REASON, errorKindFor } from "components/map/layerStatus";
import LayersControl from "components/map/LayersControl";
import FloatingMapControl from "components/map/FloatingMapControl";
@@ -125,6 +125,20 @@ function watchShapefileLoad(olLayer, layerName, setStatus) {
]);
}
+// Source types that can resolve a coordinate reference out of their own data,
+// so their config carries no code to inspect.
+const CRS_BEARING_SOURCES = ["GeoTIFF", "Zarr", "Shapefile"];
+
+// Whether a layer could need a projection definition registered. Answered from
+// the config alone, before anything is fetched, so the projection machinery is
+// loaded only for the dashboards that have a layer needing it.
+function needsProjectionRegistry(layerConfig) {
+ const source = layerConfig?.props?.source;
+ if (CRS_BEARING_SOURCES.includes(source?.type)) return true;
+ const code = source?.props?.projection;
+ return typeof code === "string" && code !== "" && !isNativelyResolvable(code);
+}
+
// Detach a shapefile source's load listeners. Paired with every abort: the
// loader already declines to report anything once superseded, and this closes
// the other half by making sure nothing is listening if it ever did.
@@ -547,6 +561,25 @@ const MapComponent = ({
// setup constants for handling new layers
const customLayers = layers ?? [];
+
+ // proj4, wkt-parser and the definition table are ~150 KiB and cost a
+ // measurable registration pass, and most dashboards have no layer needing
+ // any of it -- so the module is loaded here rather than imported
+ // statically. Awaited before any layer is built: importing it registers
+ // the table codes, and a layer that merely *names* one needs the
+ // definition on hand by the time OpenLayers resolves it, with nothing
+ // asking for it by name.
+ //
+ // The trigger is deliberately broad. A source that reads its own CRS out
+ // of its data -- a GeoTIFF or Zarr from the file, a shapefile from its
+ // .prj -- carries no code in its config to check, so the type is enough
+ // to require it. The view projection is never a table code (it starts at
+ // EPSG:3857 and the auto-fit only adopts natively-resolvable codes), so
+ // there is nothing to register before this point.
+ if (customLayers.some(needsProjectionRegistry)) {
+ await import("components/map/projections");
+ }
+
let failedLayers = [];
// Replacement layers added hidden until painted, then revealed on swap.
const buffered = [];
diff --git a/reactapp/components/map/projectionCodes.js b/reactapp/components/map/projectionCodes.js
new file mode 100644
index 00000000..857786fb
--- /dev/null
+++ b/reactapp/components/map/projectionCodes.js
@@ -0,0 +1,34 @@
+// Whether a projection code needs anything registered before OpenLayers can
+// resolve it.
+//
+// Deliberately its own module, with no dependencies. The answer is a property of
+// the code itself, so it needs neither proj4 nor the definition table -- and
+// keeping it separate is what lets the map ask the question without pulling
+// ~150 KiB of projection machinery into the main bundle for every dashboard,
+// including the ones with no layer that needs it. See projections.js, which is
+// loaded on demand.
+
+/**
+ * Whether OpenLayers resolves this code on its own, with nothing registered.
+ *
+ * Asked by code rather than by registry lookup, because once a definition is
+ * registered the two are indistinguishable through the registry -- which is the
+ * whole point of the question. Used to keep the raster auto-fit from adopting a
+ * newly-registered projection as the map's *view* projection: adoption calls
+ * setView and publishes the adopted code into the map-extent variable other
+ * visualizations read, so widening it is a separate change with its own
+ * verification. Registered projections still serve as data projections, so a
+ * raster in one renders by reprojection instead.
+ *
+ * @param {string} code Projection code.
+ * @returns {boolean}
+ */
+export function isNativelyResolvable(code) {
+ if (typeof code !== "string") return false;
+ const match = /^EPSG:(\d+)$/.exec(code.trim());
+ if (!match) return false;
+ const id = Number(match[1]);
+ if ([4326, 3857, 900913, 102100].includes(id)) return true;
+ // OpenLayers ships a UTM projection factory covering the WGS84 zones.
+ return (id > 32600 && id < 32661) || (id > 32700 && id < 32761);
+}
diff --git a/reactapp/components/map/projections.js b/reactapp/components/map/projections.js
index b3d544b2..f477be20 100644
--- a/reactapp/components/map/projections.js
+++ b/reactapp/components/map/projections.js
@@ -24,13 +24,15 @@ import wktParser from "wkt-parser";
// table entry plus a control point, and `ensureProjection` registers it on
// demand rather than at startup.
//
-// Registration is deliberately *not* done for the whole table at load time.
// `register` builds pairwise transforms across every registered code, so its
// cost is quadratic: measured at 99ms for two definitions on top of proj4's
// built-ins, and hundreds of milliseconds once a State-Plane-sized set is in
-// play. This module is imported statically by the map, so that cost would land
-// before first render on every dashboard, including the ones with no layer that
-// needs it.
+// play. That cost, plus proj4 and wkt-parser themselves at ~150 KiB, is why
+// nothing imports this module statically -- the map awaits it only when a layer
+// could carry or name a non-native CRS, and the shapefile pipeline loads it from
+// inside its own async read. Importing this module registers INITIAL_CODES as a
+// side effect (see the bottom of the file), which is what puts a table code on
+// hand for a layer that names one and nothing else asks for.
// Definitions, extents and control points are taken from PROJ's EPSG database
// rather than hand-derived. The control points sit away from each projection's
@@ -66,32 +68,6 @@ export const INITIAL_CODES = ["EPSG:5041", "EPSG:5070"];
// collide with -- a real EPSG code.
const WKT_CODE_PREFIX = "WKT:";
-/**
- * Whether OpenLayers resolves this code on its own, without anything registered
- * here.
- *
- * Asked by code rather than by registry lookup, because once a definition is
- * registered the two are indistinguishable through the registry -- which is the
- * whole point of the question. Used to keep the raster auto-fit from adopting a
- * newly-registered projection as the map's *view* projection: adoption calls
- * setView and publishes the adopted code into the map-extent variable other
- * visualizations read, so widening it is a separate change with its own
- * verification. Registered projections still serve as data projections, so a
- * raster in one renders by reprojection instead.
- *
- * @param {string} code Projection code.
- * @returns {boolean}
- */
-export function isNativelyResolvable(code) {
- if (typeof code !== "string") return false;
- const match = /^EPSG:(\d+)$/.exec(code.trim());
- if (!match) return false;
- const id = Number(match[1]);
- if ([4326, 3857, 900913, 102100].includes(id)) return true;
- // OpenLayers ships a UTM projection factory covering the WGS84 zones.
- return (id > 32600 && id < 32661) || (id > 32700 && id < 32761);
-}
-
// Registering a definition does not give the resulting projection an extent:
// `register` builds it from the proj4 definition, and a proj4 definition has
// nowhere to carry one. Verified against the installed versions -- the extent
diff --git a/reactapp/components/map/shapefile/acquire.js b/reactapp/components/map/shapefile/acquire.js
index 86d4dc02..902e7f9b 100644
--- a/reactapp/components/map/shapefile/acquire.js
+++ b/reactapp/components/map/shapefile/acquire.js
@@ -26,11 +26,11 @@ const FETCH_STAGE_CAUSES =
"The likely causes are missing cross-origin headers on the host, an unreachable host, a URL that no longer exists, or an expired signature on a signed URL.";
// Components whose absence cannot change how the layer draws: the encoding
-// falls back to a sniff of the .dbf itself, and the record index is never read.
-// Hosts disagree on what a missing object is -- an S3 bucket without
-// ListBucket returns 403, not 404 -- so for these two, any client error means
-// "not published" rather than failing a layer over a file it did not need.
-const INCONSEQUENTIAL_COMPONENTS = ["cpg", "shx"];
+// falls back to a sniff of the .dbf itself. Hosts disagree on what a missing
+// object is -- an S3 bucket without ListBucket returns 403, not 404 -- so any
+// client error here means "not published" rather than failing a layer over a
+// file it did not need. Most shapefiles ship no .cpg at all.
+const INCONSEQUENTIAL_COMPONENTS = ["cpg"];
function fetchFailure(reason, detail, extra = {}) {
return { error: { stage: "fetch", reason, detail, ...extra } };
diff --git a/reactapp/components/map/shapefile/index.js b/reactapp/components/map/shapefile/index.js
index feb5c51d..49637494 100644
--- a/reactapp/components/map/shapefile/index.js
+++ b/reactapp/components/map/shapefile/index.js
@@ -1,8 +1,4 @@
import { strFromU8 } from "fflate";
-import {
- ensureProjection,
- registerProjectionDefinition,
-} from "components/map/projections";
import { prepareAttributes } from "components/map/shapefile/attributes";
/**
@@ -35,7 +31,10 @@ export async function interpretShapefile(
};
}
- const projection = resolveProjection(components.prj, fallbackProjection);
+ const projection = await resolveProjection(
+ components.prj,
+ fallbackProjection,
+ );
if (projection.error) return projection;
// Loaded lazily so the parser stays out of the main bundle, matching how the
@@ -108,7 +107,15 @@ function looksLikeDefinition(value) {
// supplied; a .prj that failed to arrive was already reported upstream and never
// reaches this function, because silently substituting a fallback for it would
// draw the features somewhere else with no error at all.
-function resolveProjection(prjBytes, fallbackProjection) {
+async function resolveProjection(prjBytes, fallbackProjection) {
+ // Loaded here rather than imported statically: proj4, wkt-parser and the
+ // definition table are ~150 KiB, and this module reaches the main bundle
+ // through the layer loader, which every dashboard with a map evaluates. A
+ // shapefile always needs them -- it carries its CRS as WKT -- so the load is
+ // paid once, on the read, alongside the parser's own dynamic imports.
+ const { ensureProjection, registerProjectionDefinition } =
+ await import("components/map/projections");
+
if (prjBytes) {
// Decoded with fflate rather than TextDecoder: this runs in the browser and
// under the test runner, and one of those has no TextDecoder.
diff --git a/reactapp/components/map/shapefile/siblings.js b/reactapp/components/map/shapefile/siblings.js
index 94e64052..d8a6f06d 100644
--- a/reactapp/components/map/shapefile/siblings.js
+++ b/reactapp/components/map/shapefile/siblings.js
@@ -1,9 +1,15 @@
-// The components of an unzipped shapefile. `shp` carries geometry, `dbf`
-// attributes, `prj` the coordinate reference system as WKT, `shx` the record
-// index, and `cpg` the character encoding the `dbf` was written in. Only `shp`
-// is required; `shp` is first because the fetch loop treats it as the one whose
-// absence is fatal.
-export const COMPONENT_EXTENSIONS = ["shp", "dbf", "prj", "shx", "cpg"];
+// The components of an unzipped shapefile this pipeline reads. `shp` carries
+// geometry, `dbf` attributes, `prj` the coordinate reference system as WKT, and
+// `cpg` the character encoding the `dbf` was written in. Only `shp` is required;
+// `shp` is first because the fetch loop treats it as the one whose absence is
+// fatal.
+//
+// `.shx` is deliberately absent. It is the record index, and nothing here reads
+// it -- the parser is handed the .shp and .dbf and walks them sequentially.
+// Fetching it cost a round-trip per unzipped shapefile, and in the archive path
+// it was inflated into memory and charged against the size ceiling, which at
+// four bytes per record is real headroom on a large file.
+export const COMPONENT_EXTENSIONS = ["shp", "dbf", "prj", "cpg"];
const ALLOWED_PROTOCOLS = ["http:", "https:"];
diff --git a/tethysapp/tethysdash/plugin_helpers.py b/tethysapp/tethysdash/plugin_helpers.py
index d93c61a4..bd881190 100644
--- a/tethysapp/tethysdash/plugin_helpers.py
+++ b/tethysapp/tethysdash/plugin_helpers.py
@@ -546,6 +546,50 @@ def validate_feature_collection(data):
"tileSize": "Tile Size (e.g., 256, 512)",
},
},
+ # Source types the browser reads directly. They reached the frontend's
+ # sourcePropertiesOptions registry without being added here, so a plugin
+ # author following the documented builder path was refused a source type the
+ # editor offers. Kept in step with reactapp/components/map/utilities.js.
+ "Shapefile": {
+ "required": {
+ "url": "URL of a zipped shapefile (.zip) or of its .shp component",
+ },
+ "optional": {
+ # Used only when the source carries no .prj, which is the only way
+ # to place one that has none.
+ "projection": "EPSG:, or a WKT/proj4 definition",
+ "attributions": "Attributions",
+ },
+ },
+ "GeoTIFF": {
+ "required": {
+ "url": "Cloud Optimized GeoTIFF URL",
+ },
+ "optional": {
+ "projection": "EPSG:",
+ "mask_below": "Mask values at or below this",
+ },
+ },
+ "Zarr": {
+ "required": {
+ "url": "Zarr store URL (https or s3 bucket)",
+ "variable": "Variable / array name (e.g. depth)",
+ },
+ "optional": {
+ "index": "Slice index, or a variable input reference",
+ "mask_below": "Mask values at or below this",
+ },
+ },
+ "Static Image": {
+ "required": {
+ "url": "https://example.com/image.png",
+ "projection": "EPSG:4326",
+ "imageExtent": "minX,minY,maxX,maxY",
+ },
+ "optional": {
+ "attributions": "Attributions",
+ },
+ },
}
@@ -576,12 +620,20 @@ def __init__(self, name, layer_source):
- 'Vector Tile'
- 'PMTiles Vector'
- 'PMTiles Raster'
+ - 'Shapefile'
+ - 'GeoTIFF'
+ - 'Zarr'
+ - 'Static Image'
Raises:
ValueError: If layer_source is not one of the supported options.
"""
self.name = name
+ # Layer classes mirror getLayerType in
+ # reactapp/components/modals/MapLayer/MapLayer.js, which is what the
+ # editor writes into a saved config -- a plugin-built layer has to match
+ # or it renders through the wrong OL class.
valid_sources = {
"Vector Tile": "VectorTileLayer",
"Image Tile": "TileLayer",
@@ -592,6 +644,10 @@ def __init__(self, name, layer_source):
"KML": "VectorLayer",
"PMTiles Vector": "VectorTileLayer",
"PMTiles Raster": "TileLayer",
+ "Shapefile": "VectorLayer",
+ "GeoTIFF": "WebGLTile",
+ "Zarr": "WebGLTile",
+ "Static Image": "ImageLayer",
}
if layer_source not in valid_sources:
diff --git a/tethysapp/tethysdash/tests/unit_tests/test_plugin_helpers.py b/tethysapp/tethysdash/tests/unit_tests/test_plugin_helpers.py
index 376728b9..30a50213 100644
--- a/tethysapp/tethysdash/tests/unit_tests/test_plugin_helpers.py
+++ b/tethysapp/tethysdash/tests/unit_tests/test_plugin_helpers.py
@@ -1501,3 +1501,42 @@ def test_builder_runtime_geojson_override():
]["type"]
== "Point"
)
+
+
+@pytest.mark.parametrize(
+ "layer_source,expected_type",
+ [
+ ("Shapefile", "VectorLayer"),
+ ("GeoTIFF", "WebGLTile"),
+ ("Zarr", "WebGLTile"),
+ ("Static Image", "ImageLayer"),
+ ],
+)
+def test_builder_accepts_the_client_side_source_types(layer_source, expected_type):
+ # These reached the frontend's source registry without being added to this
+ # builder, so a plugin author following the documented path was refused a
+ # source type the layer editor offers. The layer class has to match what
+ # getLayerType writes, or a plugin-built layer renders through the wrong OL
+ # class.
+ builder = LayerConfigurationBuilder("test", layer_source)
+
+ assert builder.config["configuration"]["type"] == expected_type
+ assert (
+ builder.config["configuration"]["props"]["source"]["type"] == layer_source
+ )
+
+
+def test_builder_exposes_shapefile_source_properties():
+ builder = LayerConfigurationBuilder("Basins", "Shapefile")
+
+ available = builder.get_available_source_properties()
+
+ assert list(available["required"]) == ["url"]
+ # `projection` is optional and is the only way to place a shapefile that
+ # carries no .prj, so it has to be discoverable here.
+ assert "projection" in available["optional"]
+
+
+def test_builder_still_refuses_a_source_it_does_not_know():
+ with pytest.raises(ValueError, match="Invalid layer_source"):
+ LayerConfigurationBuilder("test", "Shapefile Tile")
From f0b91f3a032ac298cce08c21187feb787f3a7651 Mon Sep 17 00:00:00 2001
From: Corey Krewson
Date: Wed, 26 Aug 2026 10:32:12 -0700
Subject: [PATCH 18/18] docs: bring the plugin-authoring page in step with the
builder and the editor
Three drifts, all introduced by this branch and all in docs/source/plugins.rst,
which is the plugin-authoring page rather than the GUI one and so was missed.
The builder's supported-source list named nine types. It now accepts thirteen --
Shapefile, GeoTIFF, Zarr and Static Image were added to valid_sources in this
branch, so the page understated what a plugin author can build.
clickTolerance and snapToFeatures each enumerate the sources they apply to, and
both omitted Shapefile. That was a direct contradiction: layer_tab.rst was
updated to say a Shapefile layer supports both, so the same product documented
opposite answers on two pages depending on whether the reader came from the
editor or from the plugin API.
Separately, source_tab.rst claimed an oversized source is "refused before it is
expanded". That was true when written and is now only half true: a member
declaring an oversized size is still refused before inflating, but one that
under-declares is caught as the extra bytes arrive -- which is the whole point of
the accounting fix, since the declared size cannot be trusted as a bound. The
sentence now says both, because a doc promising the stronger guarantee is how the
original defect went unnoticed.
Sphinx warning count unchanged from main at 12; the remaining plugins.rst errors
are pre-existing title-style and duplicate-target issues at lines 52-849.
Co-Authored-By: Claude Opus 5 (1M context)
---
docs/source/maps/source_tab.rst | 2 +-
docs/source/plugins.rst | 8 ++++++--
2 files changed, 7 insertions(+), 3 deletions(-)
diff --git a/docs/source/maps/source_tab.rst b/docs/source/maps/source_tab.rst
index 54abc5e5..937e1ddb 100644
--- a/docs/source/maps/source_tab.rst
+++ b/docs/source/maps/source_tab.rst
@@ -124,7 +124,7 @@ Styling, popups, attribute variables and snapping all behave as they do for a :r
**Reading the fields.** The Style and Attributes tabs need the ``.dbf`` field names, which means reading the source. Because that can be a large download, it happens when you ask for it: use **Read shapefile fields** on this tab. One read serves both tabs. If the saved style rules, popup settings or attribute variables name a field the source no longer has — after the file is republished with a renamed column, for instance — the field is listed so the affected rules can be corrected. Those rules will not match anything until then, and the layer will still draw.
-**Size limit.** A shapefile is read in one piece, so the components are limited to 25 MB once decompressed. A source above the limit is refused before it is expanded, and the message states both the observed and the permitted size. Clip or simplify the data, or serve a reduced copy.
+**Size limit.** A shapefile is read in one piece, so the components are limited to 25 MB once decompressed. An archive that declares an oversized component is refused before that component is expanded; one that under-declares is refused as soon as the extra bytes arrive, so the declared size cannot be used to get past the limit. Either way the message states both the observed and the permitted size. Clip or simplify the data, or serve a reduced copy.
.. note::
diff --git a/docs/source/plugins.rst b/docs/source/plugins.rst
index 88ead044..6a87aa9d 100644
--- a/docs/source/plugins.rst
+++ b/docs/source/plugins.rst
@@ -995,8 +995,8 @@ from the plugin
- **minZoom** (optional): The minimum view zoom level (exclusive) above which this layer will be visible.
- **maxZoom** (optional): The maximum view zoom level (inclusive) at which this layer will be visible.
- **minZoomQuery** (optional): The minimum view zoom level (inclusive) at which this layer can be queried. If the mp is clicked beyond the zoom level, then the map will zoom into the minZoomQuery value.
- - **clickTolerance** (optional): Pixel tolerance for clicking and hovering features. For "ESRI Image and Map Service" sources this is the server identify tolerance (default 10); for "GeoJSON" and "ESRI Feature Service" sources it widens the on-screen hit area (default 0). Also sets the snap radius when snapToFeatures is enabled (default 15). See maps :ref:`feature_snapping` for details.
- - **snapToFeatures** (optional): A boolean. When true, the cursor snaps to the nearest feature of this layer on hover and a click selects that feature directly. Supported for "ESRI Image and Map Service", "GeoJSON", and "ESRI Feature Service" sources. See maps :ref:`feature_snapping` for details.
+ - **clickTolerance** (optional): Pixel tolerance for clicking and hovering features. For "ESRI Image and Map Service" sources this is the server identify tolerance (default 10); for "GeoJSON", "ESRI Feature Service" and "Shapefile" sources it widens the on-screen hit area (default 0). Also sets the snap radius when snapToFeatures is enabled (default 15). See maps :ref:`feature_snapping` for details.
+ - **snapToFeatures** (optional): A boolean. When true, the cursor snaps to the nearest feature of this layer on hover and a click selects that feature directly. Supported for "ESRI Image and Map Service", "GeoJSON", "ESRI Feature Service" and "Shapefile" sources. See maps :ref:`feature_snapping` for details.
- **snapSublayer** (optional): For "ESRI Image and Map Service" snap layers — the MapServer sublayer used to load snapping features. Defaults to the first id in the source's LAYERS "show:N" parameter, else 0.
- **layerVisibility** (optional): A boolean indicating the default visibility of the layer.
- **style** (required): An object that contains the metadata for styling. See maps :ref:`style_tab` for more information.
@@ -1022,6 +1022,10 @@ from the plugin
- ``Vector Tile``
- ``PMTiles Vector``
- ``PMTiles Raster``
+ - ``Shapefile``
+ - ``GeoTIFF``
+ - ``Zarr``
+ - ``Static Image``
**Builder methods:**