diff --git a/docs/source/maps/source_tab.rst b/docs/source/maps/source_tab.rst
index 58fe3c91..b7344bf5 100644
--- a/docs/source/maps/source_tab.rst
+++ b/docs/source/maps/source_tab.rst
@@ -246,6 +246,70 @@ The GeoTIFF source overlays a Cloud-Optimized GeoTIFF (COG) on the map at its na
------------------------------------------------------------------------------------------------------------------------
+++++
+Zarr
+++++
+
+The Zarr source renders a raster layer from a public `Zarr `_ store with no pre-processing. TethysDash reads the chosen variable slice on demand, converts it to a Cloud-Optimized GeoTIFF in memory, and draws it — so you supply a store URL and a variable rather than a prepared COG. It is styled and queried like a GeoTIFF layer: pick a color ramp on the :ref:`style_tab` and click the map for pixel values.
+
+**Layer Properties:**
+ - **url:** (required) Public URL of the Zarr store (an ``https`` bucket or ``s3://`` URL).
+ - **variable:** (required) Array name to read (e.g. ``depth``).
+ - **index:** (optional) Slice index along the store's leading dimension for stacked ``[n, y, x]`` data (default ``0``). Bind it to a :ref:`variable input ` with ``${Variable Name}`` to switch slices on the fly — for example, drive it with a slider to animate.
+ - **mask_below:** (optional) Sample values at or below this number render transparent. Leave blank to use the store's own threshold, if it declares one.
+
+**Example JSON Configuration:**
+
+::
+
+ {
+ "type": "WebGLTile",
+ "props": {
+ "name": "Flood Depth",
+ "source": {
+ "type": "Zarr",
+ "props": {
+ "url": "https://example.com/floodmaps.zarr",
+ "variable": "depth",
+ "index": "${Storm}"
+ }
+ }
+ }
+ }
+
+------------------------------------------------------------------------------------------------------------------------
+
+++++++++++
+GeoParquet
+++++++++++
+
+The GeoParquet source renders a **vector** layer from a public `GeoParquet `_ file. TethysDash reads the file on demand, converts it to GeoJSON (reprojected to EPSG:4326) in memory, and draws it — so you supply a file URL rather than a prepared GeoJSON. It is styled and queried like a GeoJSON layer.
+
+**Layer Properties:**
+ - **url:** (required) Public URL of the GeoParquet file (an ``https`` bucket or ``s3://`` URL).
+
+.. note::
+ The whole file is converted to GeoJSON and sent to the browser, so this suits moderate feature counts; very large files may be slow to load.
+
+**Example JSON Configuration:**
+
+::
+
+ {
+ "type": "VectorLayer",
+ "props": {
+ "name": "Buildings",
+ "source": {
+ "type": "GeoParquet",
+ "props": {
+ "url": "https://example.com/buildings.parquet"
+ }
+ }
+ }
+ }
+
+------------------------------------------------------------------------------------------------------------------------
+
+++++++++++++
Custom Layers
+++++++++++++
diff --git a/package-lock.json b/package-lock.json
index cdb6cc64..28f92993 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -33,6 +33,7 @@
"dompurify": "^3.1.6",
"dotenv": "^16.0.1",
"file-loader": "^6.2.0",
+ "geotiff": "2.1.3",
"html-react-parser": "^5.1.18",
"html2canvas": "^1.4.1",
"json5": "^2.2.3",
diff --git a/package.json b/package.json
index 12b3d77c..198f2531 100644
--- a/package.json
+++ b/package.json
@@ -43,6 +43,7 @@
"dompurify": "^3.1.6",
"dotenv": "^16.0.1",
"file-loader": "^6.2.0",
+ "geotiff": "2.1.3",
"html-react-parser": "^5.1.18",
"html2canvas": "^1.4.1",
"json5": "^2.2.3",
diff --git a/pyproject.toml b/pyproject.toml
index e41e632e..cf63717b 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -34,7 +34,15 @@ dependencies = [
"psycopg2-binary==2.9.11",
"geoalchemy2==0.18.4",
"tethys-platform>=4.5.0",
- "python-dateutil"
+ "python-dateutil",
+ "numpy",
+ "zarr>=3.0",
+ "fsspec",
+ "aiohttp",
+ "rasterio>=1.3",
+ "rio-cogeo>=5.0",
+ "geopandas",
+ "pyarrow"
]
classifiers = [
"Environment :: Web Environment",
diff --git a/reactapp/__tests__/components/inputs/custom/SliderMetadata.test.js b/reactapp/__tests__/components/inputs/custom/SliderMetadata.test.js
index 0810a12b..59746d0d 100644
--- a/reactapp/__tests__/components/inputs/custom/SliderMetadata.test.js
+++ b/reactapp/__tests__/components/inputs/custom/SliderMetadata.test.js
@@ -595,3 +595,46 @@ test("SliderMetadata includes alignSteps and alignOffset in onChange when enable
}),
);
});
+
+test("Number slider defaults Output Format to {{n}} when left empty", async () => {
+ const mockOnChange = jest.fn();
+
+ render(
+
+
+
+
+ ,
+ );
+
+ await selectEvent.select(screen.getByLabelText("Data Type Input"), "Number");
+ fireEvent.change(screen.getByLabelText("Minimum Input"), {
+ target: { value: "0" },
+ });
+ fireEvent.change(screen.getByLabelText("Maximum Input"), {
+ target: { value: "100" },
+ });
+ fireEvent.change(screen.getByLabelText("Step Input"), {
+ target: { value: "1" },
+ });
+
+ selectEvent.openMenu(screen.getByLabelText("Initial Value"));
+ fireEvent.click(await screen.findByText("50"));
+
+ // Output Format was never typed by the user -> defaulted to {{n}}, so the
+ // metadata is emitted and the save gate isn't blocked.
+ expect(mockOnChange).toHaveBeenLastCalledWith(
+ expect.objectContaining({
+ dataType: "Number",
+ min: 0,
+ max: 100,
+ step: 1,
+ initialValue: 50,
+ outputFormat: "{{n}}",
+ }),
+ );
+});
diff --git a/reactapp/__tests__/components/map/Map.test.js b/reactapp/__tests__/components/map/Map.test.js
index 33b45689..855100f1 100644
--- a/reactapp/__tests__/components/map/Map.test.js
+++ b/reactapp/__tests__/components/map/Map.test.js
@@ -1260,6 +1260,89 @@ test("Double-buffering done() is idempotent when called twice", async () => {
jest.useRealTimers();
});
+const tileLayer = (url) => [
+ {
+ type: "WebGLTile",
+ props: {
+ source: { type: "Image Tile", props: { url } },
+ name: "buf_layer",
+ zIndex: 0,
+ },
+ },
+];
+
+const wrapLayers = (layers) => (
+
+
+
+
+
+);
+
+test("replacement tile layer stays hidden until painted, then reveals on swap", async () => {
+ const addLayerSpy = jest.spyOn(Map.prototype, "addLayer");
+ const removeLayerSpy = jest.spyOn(Map.prototype, "removeLayer");
+
+ const { rerender } = render(
+ wrapLayers(tileLayer("https://example.com/a/{z}/{y}/{x}")),
+ );
+ expect(await screen.findByText("Map Ready")).toBeInTheDocument();
+ await waitFor(() => expect(addLayerSpy.mock.calls.length).toBe(1));
+
+ rerender(wrapLayers(tileLayer("https://example.com/b/{z}/{y}/{x}")));
+ await waitFor(() => expect(addLayerSpy.mock.calls.length).toBe(2));
+
+ const newLayer = addLayerSpy.mock.calls[1][0];
+ // Hidden via opacity (still loading) while the old layer is still present.
+ expect(newLayer.getOpacity()).toBe(0);
+ expect(removeLayerSpy.mock.calls.length).toBe(0);
+
+ newLayer.getSource().dispatchEvent("tileloadend");
+
+ await waitFor(() => expect(removeLayerSpy.mock.calls.length).toBe(1));
+ // Revealed on the swap; the old layer is the one removed.
+ expect(newLayer.getOpacity()).toBe(1);
+ expect(removeLayerSpy.mock.calls[0][0].values_.name).toBe("buf_layer");
+});
+
+test("superseded frame is discarded; only the newest replacement reveals", async () => {
+ const addLayerSpy = jest.spyOn(Map.prototype, "addLayer");
+ const removeLayerSpy = jest.spyOn(Map.prototype, "removeLayer");
+
+ const { rerender } = render(
+ wrapLayers(tileLayer("https://example.com/a/{z}/{y}/{x}")),
+ );
+ expect(await screen.findByText("Map Ready")).toBeInTheDocument();
+ await waitFor(() => expect(addLayerSpy.mock.calls.length).toBe(1));
+
+ // Two rerenders before any tiles paint: the middle frame is superseded.
+ rerender(wrapLayers(tileLayer("https://example.com/b/{z}/{y}/{x}")));
+ await waitFor(() => expect(addLayerSpy.mock.calls.length).toBe(2));
+ rerender(wrapLayers(tileLayer("https://example.com/c/{z}/{y}/{x}")));
+ await waitFor(() => expect(addLayerSpy.mock.calls.length).toBe(3));
+
+ const layerA = addLayerSpy.mock.calls[0][0];
+ const layerB = addLayerSpy.mock.calls[1][0];
+ const layerC = addLayerSpy.mock.calls[2][0];
+
+ // Both replacements are still hidden (opacity 0); the original is untouched.
+ expect(layerA.getOpacity()).toBe(1);
+ expect(layerB.getOpacity()).toBe(0);
+ expect(layerC.getOpacity()).toBe(0);
+
+ // The newest frame paints first: it reveals and the old layer is dropped.
+ layerC.getSource().dispatchEvent("tileloadend");
+ await waitFor(() => expect(layerC.getOpacity()).toBe(1));
+ expect(removeLayerSpy.mock.calls.map((c) => c[0])).toContain(layerA);
+
+ // The superseded middle frame paints late: discarded, never revealed.
+ layerB.getSource().dispatchEvent("tileloadend");
+ await waitFor(() =>
+ expect(removeLayerSpy.mock.calls.map((c) => c[0])).toContain(layerB),
+ );
+ expect(layerB.getOpacity()).toBe(0);
+});
+
test("GeoTIFF with empty sources is silently skipped (not a failed layer)", async () => {
const addLayerSpy = jest.spyOn(Map.prototype, "addLayer");
const layers = [
diff --git a/reactapp/__tests__/components/map/ModuleLoader.test.js b/reactapp/__tests__/components/map/ModuleLoader.test.js
index 030a1e6e..126f84fe 100644
--- a/reactapp/__tests__/components/map/ModuleLoader.test.js
+++ b/reactapp/__tests__/components/map/ModuleLoader.test.js
@@ -11,6 +11,8 @@ import moduleLoader, {
loadESRIJSON,
buildPolygonFill,
withAntimeridianFix,
+ zarrSourceToGeoTIFF,
+ geoParquetToGeoJSON,
} from "components/map/ModuleLoader";
import WebGLTile from "ol/layer/WebGLTile.js";
import ImageLayer from "ol/layer/Image.js";
@@ -1959,3 +1961,49 @@ describe("withAntimeridianFix", () => {
);
});
});
+
+describe("zarrSourceToGeoTIFF", () => {
+ test("assembles the zarr/cog endpoint URL from the source fields", () => {
+ const out = zarrSourceToGeoTIFF({
+ type: "Zarr",
+ props: { url: "https://x/store.zarr", variable: "depth", index: "150" },
+ });
+ expect(out.type).toBe("GeoTIFF");
+ expect(out.props.normalize).toBe(true);
+ const url = out.props.sources[0].url;
+ expect(url).toContain("/apps/tethysdash/zarr/cog/?");
+ expect(url).toContain("src=https%3A%2F%2Fx%2Fstore.zarr");
+ expect(url).toContain("variable=depth");
+ expect(url).toContain("index=150");
+ expect(url).not.toContain("mask_below");
+ });
+
+ test("defaults index to 0 and includes mask_below when provided", () => {
+ const out = zarrSourceToGeoTIFF({
+ type: "Zarr",
+ props: { url: "https://x", variable: "t", mask_below: "0.5" },
+ });
+ const url = out.props.sources[0].url;
+ expect(url).toContain("index=0");
+ expect(url).toContain("mask_below=0.5");
+ });
+});
+
+describe("geoParquetToGeoJSON", () => {
+ test("assembles the geoparquet/geojson endpoint URL from the source url", () => {
+ const out = geoParquetToGeoJSON({
+ type: "GeoParquet",
+ props: { url: "https://x/data.parquet" },
+ });
+ expect(out.type).toBe("GeoJSON");
+ expect(out.props).toEqual({});
+ expect(out.geojson).toContain("/apps/tethysdash/geoparquet/geojson/?");
+ expect(out.geojson).toContain("src=https%3A%2F%2Fx%2Fdata.parquet");
+ });
+
+ test("handles a missing url without throwing", () => {
+ const out = geoParquetToGeoJSON({ type: "GeoParquet", props: {} });
+ expect(out.type).toBe("GeoJSON");
+ expect(out.geojson).toContain("geoparquet/geojson/?src=");
+ });
+});
diff --git a/reactapp/__tests__/components/map/geoTIFFStyle.test.js b/reactapp/__tests__/components/map/geoTIFFStyle.test.js
index 3fc5b453..14a315a3 100644
--- a/reactapp/__tests__/components/map/geoTIFFStyle.test.js
+++ b/reactapp/__tests__/components/map/geoTIFFStyle.test.js
@@ -125,7 +125,18 @@ describe("buildGeoTIFFStyleColor", () => {
rampMin: "not-a-number",
rampMax: 100,
}),
- ).toThrow(/finite numbers/);
+ ).toThrow(/both be set or both empty/);
+ });
+
+ test("empty rampMin and rampMax build a normalized [0,1] interpolate", () => {
+ const expr = buildGeoTIFFStyleColor({
+ rampName: "viridis",
+ rampMin: "",
+ rampMax: "",
+ });
+ expect(expr[0]).toBe("interpolate");
+ expect(expr[3]).toBe(0); // first stop at 0
+ expect(expr[expr.length - 2]).toBe(1); // last stop at 1
});
test("hasNodata wraps the interpolate in a `case` against band 2 with a transparent fallback", () => {
@@ -157,7 +168,7 @@ describe("buildGeoTIFFStyleColor", () => {
rampMin: 0,
rampMax: "",
}),
- ).toThrow(/finite numbers/);
+ ).toThrow(/both be set or both empty/);
});
test("treats an empty-string rampMin as NaN (covers the minIsEmpty true branch)", () => {
@@ -170,7 +181,7 @@ describe("buildGeoTIFFStyleColor", () => {
rampMin: "",
rampMax: 100,
}),
- ).toThrow(/finite numbers/);
+ ).toThrow(/both be set or both empty/);
});
test("steps === 1 short-circuits to t=0 (single-entry ramp covers the steps===1 branch)", () => {
diff --git a/reactapp/__tests__/components/map/utilities.test.js b/reactapp/__tests__/components/map/utilities.test.js
index 153c0b38..ee884b55 100644
--- a/reactapp/__tests__/components/map/utilities.test.js
+++ b/reactapp/__tests__/components/map/utilities.test.js
@@ -1922,6 +1922,31 @@ test("queryLayerFeatures GeoTIFF returns band values at pixel", async () => {
expect(features[0].geometry).toEqual({ type: "Point", coordinates: [0, 0] });
});
+test("queryLayerFeatures Zarr dispatches to GeoTIFF pixel extraction", async () => {
+ const { map } = mockGeoTIFFMap({ getDataReturn: new Float32Array([42.5]) });
+ const zarrConfig = {
+ configuration: {
+ type: "WebGLTile",
+ props: {
+ name: "Test GeoTIFF Layer",
+ source: {
+ type: "Zarr",
+ props: { url: "https://x", variable: "depth" },
+ },
+ },
+ },
+ };
+ const features = await queryLayerFeatures(
+ zarrConfig,
+ map,
+ [0, 0],
+ [400, 300],
+ );
+
+ expect(features).toHaveLength(1);
+ expect(features[0].attributes["Band 1"]).toBeCloseTo(42.5, 4);
+});
+
test("queryLayerFeatures GeoTIFF reports multi-band values", async () => {
const { map } = mockGeoTIFFMap({
getDataReturn: new Uint8Array([12, 34, 56]),
diff --git a/reactapp/__tests__/components/modals/DataViewer/DataViewer.sliderSave.test.js b/reactapp/__tests__/components/modals/DataViewer/DataViewer.sliderSave.test.js
new file mode 100644
index 00000000..0e105c4c
--- /dev/null
+++ b/reactapp/__tests__/components/modals/DataViewer/DataViewer.sliderSave.test.js
@@ -0,0 +1,111 @@
+import { useEffect, useContext } from "react";
+import PropTypes from "prop-types";
+import { render, screen, fireEvent, waitFor } from "@testing-library/react";
+import DataViewerModal from "components/modals/DataViewer/DataViewer";
+import {
+ userDashboard,
+ mockedDateRangeVariable,
+} from "__tests__/utilities/constants";
+import createLoadedComponent, {
+ InputVariablePComponent,
+} from "__tests__/utilities/customRender";
+import { GridItemContext, TabContext } from "components/contexts/Contexts";
+
+const { ResizeObserver } = window;
+
+beforeEach(() => {
+ delete window.ResizeObserver;
+ window.ResizeObserver = jest.fn().mockImplementation(() => ({
+ observe: jest.fn(),
+ unobserve: jest.fn(),
+ disconnect: jest.fn(),
+ }));
+});
+
+afterEach(() => {
+ window.ResizeObserver = ResizeObserver;
+ jest.restoreAllMocks();
+});
+
+const TestingComponent = ({ gridItem, onTabUpdate }) => {
+ const { tabs } = useContext(TabContext);
+ useEffect(() => {
+ onTabUpdate(tabs);
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [tabs]);
+ return (
+
+
+
+ );
+};
+
+TestingComponent.propTypes = {
+ gridItem: PropTypes.object,
+ onTabUpdate: PropTypes.func,
+};
+
+const sliderGridItem = () => {
+ const gridItem = JSON.parse(JSON.stringify(mockedDateRangeVariable));
+ gridItem.args_string = JSON.stringify({
+ variable_name: "Storm",
+ show_label: true,
+ variable_options_source: "slider",
+ "variable_options_source.metadata": {
+ min: 0,
+ max: 10,
+ step: 1,
+ dataType: "Number",
+ outputFormat: "{{n}}",
+ },
+ initial_value: null,
+ });
+ return gridItem;
+};
+
+test("a slider variable input with no explicit initial value saves without a false validation error", async () => {
+ const mockedDashboard = JSON.parse(JSON.stringify(userDashboard));
+ const gridItem = sliderGridItem();
+ mockedDashboard.tabs[0].gridItems[0] = gridItem;
+ const mockUpdateTab = jest.fn();
+
+ render(
+ createLoadedComponent({
+ children: (
+ <>
+
+
+ >
+ ),
+ options: { initialDashboard: mockedDashboard },
+ }),
+ );
+
+ const saveButton = await screen.findByLabelText("dataviewer-save-button");
+
+ mockUpdateTab.mockClear();
+ fireEvent.click(saveButton);
+
+ await waitFor(() => expect(mockUpdateTab).toHaveBeenCalled());
+ expect(
+ screen.queryByText("Initial value must be selected in the dropdown"),
+ ).not.toBeInTheDocument();
+
+ const savedArgs = JSON.parse(
+ mockUpdateTab.mock.calls.at(-1)[0][0].gridItems[0].args_string,
+ );
+ expect(savedArgs.variable_options_source).toBe("slider");
+});
diff --git a/reactapp/__tests__/components/modals/MapLayer/GeoTIFFSourceModal.test.js b/reactapp/__tests__/components/modals/MapLayer/GeoTIFFSourceModal.test.js
index ab1320e1..6518c600 100644
--- a/reactapp/__tests__/components/modals/MapLayer/GeoTIFFSourceModal.test.js
+++ b/reactapp/__tests__/components/modals/MapLayer/GeoTIFFSourceModal.test.js
@@ -128,6 +128,7 @@ test("GeoTIFFSourceModal typing a URL and clicking Save calls onSave with all fi
await user.type(screen.getByLabelText("Bands Input"), "1,2,3");
await user.type(screen.getByLabelText("Min Input"), "10");
await user.type(screen.getByLabelText("Max Input"), "255");
+ await user.clear(screen.getByLabelText("Nodata Input"));
await user.type(screen.getByLabelText("Nodata Input"), "0");
await user.type(screen.getByLabelText("Projection Input"), "EPSG:4326");
@@ -245,6 +246,7 @@ test("GeoTIFFSourceModal preserves string '0' in min on save (not coerced to num
);
await user.type(screen.getByLabelText("Min Input"), "0");
await user.type(screen.getByLabelText("Max Input"), "0");
+ await user.clear(screen.getByLabelText("Nodata Input"));
await user.type(screen.getByLabelText("Nodata Input"), "0");
await user.click(
@@ -395,3 +397,9 @@ test("GeoTIFFSourceModal returns focus to returnFocusRef element on close", asyn
{ timeout: 2000 },
);
});
+
+test("new GeoTIFF source leaves Nodata empty (no assumed sentinel)", async () => {
+ render();
+ expect(await screen.findByText("Add GeoTIFF Source")).toBeInTheDocument();
+ expect(screen.getByLabelText("Nodata Input")).toHaveValue("");
+});
diff --git a/reactapp/__tests__/components/modals/MapLayer/MapLayer.test.js b/reactapp/__tests__/components/modals/MapLayer/MapLayer.test.js
index 1af23db0..141ef500 100644
--- a/reactapp/__tests__/components/modals/MapLayer/MapLayer.test.js
+++ b/reactapp/__tests__/components/modals/MapLayer/MapLayer.test.js
@@ -2354,12 +2354,14 @@ describe("MapLayerModal GeoTIFF ramp-style save path (Unit 7)", () => {
expect(savedStyle.color[3]).toBe(0);
// Last stop pair ends at rampMax.
expect(savedStyle.color[savedStyle.color.length - 2]).toBe(100);
+ // Explicit range = raw band values, so the source is not normalized.
+ expect(savedConfig.configuration.props.source.props.normalize).toBe(false);
// Most important regression guard: the backend upload was NOT called.
expect(uploadSpy).not.toHaveBeenCalled();
});
- test("GeoTIFF without a ramp name saves with no style key", async () => {
+ test("GeoTIFF with no explicit range gets a normalized style (turbo default)", async () => {
const uploadSpy = jest
.spyOn(appAPI, "uploadJSON")
.mockResolvedValue({ success: true, filename: "x.json" });
@@ -2395,11 +2397,65 @@ describe("MapLayerModal GeoTIFF ramp-style save path (Unit 7)", () => {
});
const savedConfig = addMapLayer.mock.calls[0][0];
- expect(savedConfig.configuration.style).toBeUndefined();
+ const savedStyle = savedConfig.configuration.style;
+ // Turbo default + normalized [0,1] interpolate; no persisted range.
+ expect(Array.isArray(savedStyle.color)).toBe(true);
+ expect(savedStyle.color[0]).toBe("interpolate");
+ expect(savedStyle.color[savedStyle.color.length - 2]).toBe(1);
+ expect(savedConfig.configuration.props.source.rampName).toBe("turbo");
+ expect(savedConfig.configuration.props.source.rampMin).toBeUndefined();
+ expect(savedConfig.configuration.props.source.props.normalize).toBe(true);
expect(uploadSpy).not.toHaveBeenCalled();
});
- test("GeoTIFF with rampName but empty rampMin/rampMax does not generate a style", async () => {
+ test("Zarr source saves as a WebGLTile layer with a normalized turbo ramp", async () => {
+ const handleModalClose = jest.fn();
+ const addMapLayer = jest.fn();
+ const layerInfo = {
+ layerProps: { name: "Flood Depth" },
+ sourceProps: {
+ type: "Zarr",
+ props: {
+ url: "https://x/store.zarr",
+ variable: "depth",
+ index: "5",
+ },
+ },
+ };
+
+ render(
+ ,
+ );
+
+ fireEvent.click(await screen.findByLabelText("Create Layer Button"));
+ await waitFor(() => {
+ expect(addMapLayer).toHaveBeenCalledTimes(1);
+ });
+
+ const savedConfig = addMapLayer.mock.calls[0][0];
+ // WebGLTile layer with the Zarr source and its fields preserved for editing.
+ expect(savedConfig.configuration.type).toBe("WebGLTile");
+ const source = savedConfig.configuration.props.source;
+ expect(source.type).toBe("Zarr");
+ expect(source.props.url).toBe("https://x/store.zarr");
+ expect(source.props.variable).toBe("depth");
+ expect(source.props.index).toBe("5");
+ // Turbo default + per-slice auto-scaling (normalized ramp). Zarr COGs always
+ // carry a -9999 nodata sentinel, so the ramp is wrapped in a transparency
+ // `case` expression (a GeoTIFF with no nodata set would be bare interpolate).
+ const color = savedConfig.configuration.style.color;
+ expect(color[0]).toBe("case");
+ expect(JSON.stringify(color)).toContain("interpolate");
+ expect(source.rampName).toBe("turbo");
+ expect(source.props.normalize).toBe(true);
+ });
+
+ test("GeoTIFF with rampName and empty range gets a normalized style", async () => {
const uploadSpy = jest
.spyOn(appAPI, "uploadJSON")
.mockResolvedValue({ success: true, filename: "x.json" });
@@ -2407,7 +2463,7 @@ describe("MapLayerModal GeoTIFF ramp-style save path (Unit 7)", () => {
const handleModalClose = jest.fn();
const addMapLayer = jest.fn();
const layerInfo = {
- layerProps: { name: "Incomplete Ramp GeoTIFF" },
+ layerProps: { name: "Auto Ramp GeoTIFF" },
sourceProps: {
type: "GeoTIFF",
rampName: "viridis",
@@ -2438,7 +2494,12 @@ describe("MapLayerModal GeoTIFF ramp-style save path (Unit 7)", () => {
});
const savedConfig = addMapLayer.mock.calls[0][0];
- expect(savedConfig.configuration.style).toBeUndefined();
+ const savedStyle = savedConfig.configuration.style;
+ expect(Array.isArray(savedStyle.color)).toBe(true);
+ expect(savedStyle.color[0]).toBe("interpolate");
+ expect(savedStyle.color[savedStyle.color.length - 2]).toBe(1);
+ expect(savedConfig.configuration.props.source.rampMin).toBeUndefined();
+ expect(savedConfig.configuration.props.source.props.normalize).toBe(true);
expect(uploadSpy).not.toHaveBeenCalled();
});
@@ -3780,6 +3841,14 @@ describe("getLayerType", () => {
expect(getLayerType("GeoTIFF")).toBe("WebGLTile");
});
+ test("Zarr maps to WebGLTile (renders as a COG)", () => {
+ expect(getLayerType("Zarr")).toBe("WebGLTile");
+ });
+
+ test("GeoParquet falls through to VectorLayer", () => {
+ expect(getLayerType("GeoParquet")).toBe("VectorLayer");
+ });
+
test("Vector source types map to VectorTileLayer", () => {
expect(getLayerType("Vector Tile")).toBe("VectorTileLayer");
expect(getLayerType("Vector")).toBe("VectorTileLayer");
diff --git a/reactapp/__tests__/components/modals/MapLayer/StylePane.test.js b/reactapp/__tests__/components/modals/MapLayer/StylePane.test.js
index e4448f9a..616a5d5b 100644
--- a/reactapp/__tests__/components/modals/MapLayer/StylePane.test.js
+++ b/reactapp/__tests__/components/modals/MapLayer/StylePane.test.js
@@ -6,6 +6,9 @@ import PropTypes from "prop-types";
import userEvent from "@testing-library/user-event";
import { LayoutContext, AppContext } from "components/contexts/Contexts";
import * as utilities from "components/map/utilities";
+import { fromUrl } from "geotiff";
+
+jest.mock("geotiff", () => ({ fromUrl: jest.fn() }));
const exampleStyle = {
version: 8,
@@ -261,7 +264,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",
+ "GeoParquet",
+ ];
expect(
await screen.findByText(
`Custom Styling is only available for ${supportedTypes.join(", ")} layers.`,
@@ -269,6 +277,12 @@ test("StylePane Styling not available", async () => {
).toBeInTheDocument();
});
+test("StylePane offers vector styling for a GeoParquet source", async () => {
+ render();
+ // Supported vector source -> styling controls, not the "not available" notice.
+ expect(await screen.findByLabelText("Rule-based Editor")).toBeInTheDocument();
+});
+
test("StylePane switches to rules mode and syncs rules/defaultStyle from JSON", async () => {
render();
// Switch to rules mode
@@ -531,6 +545,91 @@ test("StylePane renders Color Ramp section for GeoTIFF source type", async () =>
expect(screen.getByLabelText("Ramp Max")).toBeInTheDocument();
});
+test("StylePane defaults a GeoTIFF source's ramp to turbo when none is set", async () => {
+ render();
+ await waitFor(() => {
+ expect(screen.getByTestId("rampName")).toHaveTextContent("turbo");
+ });
+});
+
+test("StylePane renders the Color Ramp section and defaults to turbo for a Zarr source", async () => {
+ render();
+ expect(await screen.findByText("Color Ramp")).toBeInTheDocument();
+ await waitFor(() => {
+ expect(screen.getByTestId("rampName")).toHaveTextContent("turbo");
+ });
+});
+
+test("StylePane pre-fills GeoTIFF ramp min/max from statistics, and clearing sticks", async () => {
+ fromUrl.mockReset();
+ fromUrl.mockResolvedValue({
+ getImage: async () => ({
+ getGDALMetadata: () => ({
+ STATISTICS_MINIMUM: "0.05",
+ STATISTICS_MAXIMUM: "11.7",
+ }),
+ }),
+ });
+ const user = userEvent.setup();
+
+ render(
+ ,
+ );
+
+ await waitFor(() =>
+ expect(screen.getByTestId("rampMax")).toHaveTextContent("12"),
+ );
+ expect(screen.getByTestId("rampMin")).toHaveTextContent("0");
+ expect(fromUrl).toHaveBeenCalledTimes(1);
+
+ // Clearing the fields must not re-fetch/refill (opts into per-storm auto).
+ await user.clear(screen.getByLabelText("Ramp Min"));
+ await user.clear(screen.getByLabelText("Ramp Max"));
+ expect(fromUrl).toHaveBeenCalledTimes(1);
+});
+
+test("StylePane skips the stats fetch when ramp min/max are already set", async () => {
+ fromUrl.mockReset();
+
+ render(
+ ,
+ );
+
+ await screen.findByText("Color Ramp");
+ expect(fromUrl).not.toHaveBeenCalled();
+});
+
+test("StylePane does not fetch stats for a non-http source URL", async () => {
+ fromUrl.mockReset();
+
+ render(
+ ,
+ );
+
+ await screen.findByText("Color Ramp");
+ expect(fromUrl).not.toHaveBeenCalled();
+});
+
test("StylePane does NOT render Color Ramp section for non-GeoTIFF sources", async () => {
render();
// Vector editor renders instead.
diff --git a/reactapp/components/inputs/custom/SliderMetadata.js b/reactapp/components/inputs/custom/SliderMetadata.js
index 4080871a..20b75b87 100644
--- a/reactapp/components/inputs/custom/SliderMetadata.js
+++ b/reactapp/components/inputs/custom/SliderMetadata.js
@@ -188,7 +188,8 @@ const SliderMetadata = ({ onChange, values }) => {
setMax(null);
setStep(null);
setInitialValue(null);
- setOutputFormat("");
+ // Number sliders get a default format so Output Format isn't required.
+ setOutputFormat(selected?.value === "Number" ? "{{n}}" : "");
onChange(null);
};
diff --git a/reactapp/components/map/Map.js b/reactapp/components/map/Map.js
index 8779eb43..eeaeff57 100644
--- a/reactapp/components/map/Map.js
+++ b/reactapp/components/map/Map.js
@@ -75,8 +75,33 @@ const MapComponent = ({
const isFirstRender = useRef(true);
const mapExtentVariableEvent = useRef();
const currentLayers = useRef([]);
+ const layerSyncToken = useRef(0);
+ const activeFadeRef = useRef(null);
const { setVariableInputValues } = useContext(VariableInputsContext);
+ // Fade the incoming layers in over `duration` ms, then remove the outgoing
+ // ones, so a storm swap dissolves instead of flashing. Any running fade is
+ // finalized first so overlapping swaps don't leave a layer mid-fade.
+ const crossfadeLayers = (map, incoming, outgoing, duration) => {
+ if (activeFadeRef.current) activeFadeRef.current();
+ const start = Date.now();
+ let rafId = null;
+ const finalize = () => {
+ if (rafId !== null) cancelAnimationFrame(rafId);
+ incoming.forEach(({ layer, opacity }) => layer.setOpacity(opacity));
+ outgoing.forEach((layer) => map.removeLayer(layer));
+ activeFadeRef.current = null;
+ };
+ const step = () => {
+ const t = Math.min(1, (Date.now() - start) / duration);
+ incoming.forEach(({ layer, opacity }) => layer.setOpacity(opacity * t));
+ if (t < 1) rafId = requestAnimationFrame(step);
+ else finalize();
+ };
+ activeFadeRef.current = finalize;
+ rafId = requestAnimationFrame(step);
+ };
+
const defaultMapConfig = {
className: "ol-map",
style: { width: "100%", height: "100%", position: "relative" },
@@ -123,6 +148,7 @@ const MapComponent = ({
return () => {
// istanbul ignore next
if (visualizationRef.current) {
+ if (activeFadeRef.current) activeFadeRef.current();
visualizationRef.current.setTarget(undefined);
visualizationRef.current = null;
}
@@ -198,6 +224,9 @@ const MapComponent = ({
const updateLayers = async () => {
const map = visualizationRef.current;
const currentMapLayers = map.getLayers().getArray();
+ // Identify this run so a newer frame can supersede it mid-load.
+ layerSyncToken.current += 1;
+ const myToken = layerSyncToken.current;
// Clean up layers: determine which to keep and which to remove
const layersToKeep = [];
@@ -303,6 +332,8 @@ const MapComponent = ({
// setup constants for handling new layers
const customLayers = layers ?? [];
let failedLayers = [];
+ // Replacement layers added hidden until painted, then revealed on swap.
+ const buffered = [];
// Add or update layers in parallel
const layerLoadPromises = [];
@@ -371,6 +402,14 @@ const MapComponent = ({
setTimeout(done, 5000);
});
layerLoadPromises.push(loadPromise);
+ // Hide via opacity, not visibility: an invisible layer never
+ // renders, so it would never load its tiles. Opacity 0 keeps
+ // it loading; we restore the real opacity once it has painted.
+ buffered.push({
+ layer: newLayer,
+ opacity: newLayer.getOpacity(),
+ });
+ newLayer.setOpacity(0);
}
}
@@ -378,7 +417,8 @@ const MapComponent = ({
if (
layerConfig.type === "WebGLTile" &&
- layerConfig.props?.source?.type === "GeoTIFF"
+ (layerConfig.props?.source?.type === "GeoTIFF" ||
+ layerConfig.props?.source?.type === "Zarr")
) {
const geoTIFFSource = newLayer.getSource();
@@ -536,10 +576,19 @@ const MapComponent = ({
await Promise.all(layerLoadPromises);
}
- // Remove layers that are no longer needed
- layersToRemove.forEach((layer) => {
- map.removeLayer(layer);
- });
+ // Reveal painted replacements, then drop old layers in one frame.
+ // A superseded run keeps the old layer and discards its unshown buffers,
+ // so fast playback skips frames instead of flashing or stalling.
+ const superseded = myToken !== layerSyncToken.current;
+ if (buffered.length > 0 && superseded) {
+ buffered.forEach(({ layer }) => map.removeLayer(layer));
+ } else if (buffered.length > 0) {
+ crossfadeLayers(map, buffered, layersToRemove, 250);
+ } else {
+ layersToRemove.forEach((layer) => {
+ map.removeLayer(layer);
+ });
+ }
if (failedLayers.length > 0) {
setErrorMessage(
@@ -620,7 +669,11 @@ const MapComponent = ({
isFirstRender.current = false;
}
- currentLayers.current = layers ?? [];
+ // Only the winning run records the rendered layers, so a slow superseded
+ // run can't overwrite it with a stale config.
+ if (!superseded) {
+ currentLayers.current = layers ?? [];
+ }
};
updateLayers();
diff --git a/reactapp/components/map/ModuleLoader.js b/reactapp/components/map/ModuleLoader.js
index 03f58a36..62e28c0b 100644
--- a/reactapp/components/map/ModuleLoader.js
+++ b/reactapp/components/map/ModuleLoader.js
@@ -47,7 +47,53 @@ export function withAntimeridianFix(type, props) {
};
}
+const APP_ROOT_URL = process.env.TETHYS_APP_ROOT_URL ?? "/apps/tethysdash/";
+
+// A "Zarr" source is sugar over the zarr/cog endpoint: the author supplies a
+// store URL + variable (+ optional index/mask_below) and we assemble the COG
+// URL, then render it as an ordinary GeoTIFF source. Variable inputs in the
+// fields (e.g. index="${Storm}") are already substituted before this runs.
+export function zarrSourceToGeoTIFF(config) {
+ const { url, variable, index, mask_below, normalize } = config.props ?? {};
+ const params = new URLSearchParams({
+ src: url ?? "",
+ variable: variable ?? "",
+ index: index ?? "0",
+ });
+ if (mask_below !== undefined && mask_below !== "") {
+ params.set("mask_below", mask_below);
+ }
+ return {
+ ...config,
+ type: "GeoTIFF",
+ props: {
+ sources: [{ url: `${APP_ROOT_URL}zarr/cog/?${params.toString()}` }],
+ normalize: normalize ?? true,
+ },
+ };
+}
+
+// A "GeoParquet" source is sugar over the geoparquet/geojson endpoint: the
+// author supplies a file URL and we render the returned GeoJSON (EPSG:4326) as
+// an ordinary vector source.
+export function geoParquetToGeoJSON(config) {
+ const { url } = config.props ?? {};
+ const params = new URLSearchParams({ src: url ?? "" });
+ return {
+ ...config,
+ type: "GeoJSON",
+ geojson: `${APP_ROOT_URL}geoparquet/geojson/?${params.toString()}`,
+ props: {},
+ };
+}
+
const moduleLoader = async (config, mapProjection) => {
+ if (config.type === "Zarr") {
+ config = zarrSourceToGeoTIFF(config);
+ }
+ if (config.type === "GeoParquet") {
+ config = geoParquetToGeoJSON(config);
+ }
if (
config.type === "Static Image" &&
typeof config.props?.imageExtent === "string"
@@ -187,7 +233,12 @@ const resolveProps = async (props, mapProjection) => {
}
}
- if (props.sources && Array.isArray(props.sources)) {
+ if (
+ props.sources &&
+ Array.isArray(props.sources) &&
+ props.normalize === undefined
+ ) {
+ // Default raw band values unless the layer explicitly asked to normalize.
resolvedProps.normalize = false;
}
diff --git a/reactapp/components/map/geoTIFFStyle.js b/reactapp/components/map/geoTIFFStyle.js
index 9d715125..5a730dac 100644
--- a/reactapp/components/map/geoTIFFStyle.js
+++ b/reactapp/components/map/geoTIFFStyle.js
@@ -12,18 +12,25 @@ export function buildGeoTIFFStyleColor({
throw new Error(`Unknown color ramp: ${rampName}`);
}
- // Reject empty strings up front — `Number("")` silently returns 0, which
- // would otherwise pass the isFinite check and produce a degenerate expression
- // that doesn't match the user's (missing) intent.
- const minIsEmpty = typeof rampMin === "string" && rampMin.trim() === "";
- const maxIsEmpty = typeof rampMax === "string" && rampMax.trim() === "";
- const min = minIsEmpty ? NaN : Number(rampMin);
- const max = maxIsEmpty ? NaN : Number(rampMax);
-
- if (!Number.isFinite(min) || !Number.isFinite(max)) {
- throw new Error(
- `rampMin and rampMax must be finite numbers (got rampMin=${rampMin}, rampMax=${rampMax})`,
- );
+ const minIsEmpty =
+ rampMin == null || (typeof rampMin === "string" && rampMin.trim() === "");
+ const maxIsEmpty =
+ rampMax == null || (typeof rampMax === "string" && rampMax.trim() === "");
+
+ let min;
+ let max;
+ if (minIsEmpty && maxIsEmpty) {
+ // Normalized mode: OL scales band 1 to [0,1] from the file's statistics.
+ min = 0;
+ max = 1;
+ } else {
+ min = minIsEmpty ? NaN : Number(rampMin);
+ max = maxIsEmpty ? NaN : Number(rampMax);
+ if (!Number.isFinite(min) || !Number.isFinite(max)) {
+ throw new Error(
+ `rampMin and rampMax must both be set or both empty (got rampMin=${rampMin}, rampMax=${rampMax})`,
+ );
+ }
}
const steps = colors.length;
@@ -44,7 +51,7 @@ export function buildGeoTIFFStyleColor({
buildGeoTIFFStyleColor.propTypes = {
rampName: PropTypes.string.isRequired,
- rampMin: PropTypes.oneOfType([PropTypes.number, PropTypes.string]).isRequired,
- rampMax: PropTypes.oneOfType([PropTypes.number, PropTypes.string]).isRequired,
+ rampMin: PropTypes.oneOfType([PropTypes.number, PropTypes.string]),
+ rampMax: PropTypes.oneOfType([PropTypes.number, PropTypes.string]),
hasNodata: PropTypes.bool,
};
diff --git a/reactapp/components/map/utilities.js b/reactapp/components/map/utilities.js
index 43b21b7f..9fe572fc 100644
--- a/reactapp/components/map/utilities.js
+++ b/reactapp/components/map/utilities.js
@@ -18,7 +18,11 @@ 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"];
+export const CLIENT_VECTOR_SOURCE_TYPES = [
+ "GeoJSON",
+ "ESRI Feature Service",
+ "GeoParquet",
+];
// Coerce an optional numeric layer prop: GUI inputs emit strings, so accept
// any numeric value but treat null/undefined/blank/non-numeric as unset.
@@ -126,6 +130,23 @@ export const sourcePropertiesOptions = {
required: {},
optional: {},
},
+ Zarr: {
+ required: {
+ url: { placeholder: "Zarr store URL (https or s3 bucket)" },
+ variable: { placeholder: "Variable / array name (e.g. depth)" },
+ },
+ optional: {
+ // eslint-disable-next-line no-template-curly-in-string
+ index: { placeholder: "Slice index or a variable, e.g. ${Storm}" },
+ mask_below: { placeholder: "Mask values at or below this" },
+ },
+ },
+ GeoParquet: {
+ required: {
+ url: { placeholder: "GeoParquet file URL (https or s3)" },
+ },
+ optional: {},
+ },
"Vector Tile": {
required: {
urls: {
@@ -624,7 +645,7 @@ export async function queryLayerFeatures(layerInfo, map, coordinate, pixel) {
features = getVectorTileLayerFeatures(map, pixel);
} else if (sourceType === "KML") {
features = getKMLLayerFeatures(map, pixel, coordinate, LayerName);
- } else if (sourceType === "GeoTIFF") {
+ } else if (sourceType === "GeoTIFF" || sourceType === "Zarr") {
features = getGeoTIFFPixelValues(
map,
pixel,
diff --git a/reactapp/components/modals/DataViewer/DataViewer.js b/reactapp/components/modals/DataViewer/DataViewer.js
index e4e9f09b..47c0e5c2 100644
--- a/reactapp/components/modals/DataViewer/DataViewer.js
+++ b/reactapp/components/modals/DataViewer/DataViewer.js
@@ -254,7 +254,9 @@ function DataViewerModal({
return;
} else if (
variableInputValue == null &&
- !["checkbox", "csv-uploader"].includes(variableInputSource)
+ !["checkbox", "csv-uploader", "slider"].includes(
+ variableInputSource,
+ )
) {
setAlertMessage("Initial value must be selected in the dropdown");
setShowAlert(true);
@@ -264,20 +266,25 @@ function DataViewerModal({
vizInputsValues.initial_value = variableInputValue;
}
- if (
- Object.values(vizInputsValues).every(
- (value) => ![null, ""].includes(value),
- ) // TODO for csv-uploader, it's ok if data is empty
- ) {
+ const skipInitialValueCheck = ["slider", "csv-uploader"].includes(
+ vizInputsValues.variable_options_source,
+ );
+ const valuesToValidate = skipInitialValueCheck
+ ? Object.entries(vizInputsValues)
+ .filter(([key]) => key !== "initial_value")
+ .map(([, value]) => value)
+ : Object.values(vizInputsValues);
+ if (valuesToValidate.every((value) => ![null, ""].includes(value))) {
const { gridItems, id: activeTabId } = getActiveTab();
let updatedGridItems = JSON.parse(JSON.stringify(gridItems));
- updatedGridItems[gridItemIndex].source = vizMetadata.source;
+ updatedGridItems[gridItemIndex].source =
+ vizMetadata?.source ?? selectedVizTypeOption.source;
updatedGridItems[gridItemIndex].args_string = JSON.stringify(
Object.fromEntries(
Object.entries(vizInputsValues).map(([key, val]) => [
key,
- val.value ?? val,
+ val?.value ?? val,
]),
),
);
diff --git a/reactapp/components/modals/DataViewer/VisualizationPane.js b/reactapp/components/modals/DataViewer/VisualizationPane.js
index fb279464..737795dc 100644
--- a/reactapp/components/modals/DataViewer/VisualizationPane.js
+++ b/reactapp/components/modals/DataViewer/VisualizationPane.js
@@ -278,7 +278,16 @@ function VisualizationPane({
function checkAllInputs() {
if (selectedVizTypeOption) {
- const allFilled = Object.values(vizInputsValues).every(
+ // slider's initial value comes from the preview, so don't gate on it
+ const skipInitialValueCheck = ["slider", "csv-uploader"].includes(
+ vizInputsValues.variable_options_source,
+ );
+ const inputsToCheck = skipInitialValueCheck
+ ? Object.entries(vizInputsValues)
+ .filter(([key]) => key !== "initial_value")
+ .map(([, value]) => value)
+ : Object.values(vizInputsValues);
+ const allFilled = inputsToCheck.every(
(value) => !["", null].includes(value),
);
const isEmptyArgs =
@@ -306,7 +315,7 @@ function VisualizationPane({
args: Object.fromEntries(
Object.entries(vizInputsValues).map(([key, val]) => [
key,
- val.value ?? val,
+ val?.value ?? val,
]),
),
};
diff --git a/reactapp/components/modals/MapLayer/MapLayer.js b/reactapp/components/modals/MapLayer/MapLayer.js
index 34476f1c..d6e60103 100644
--- a/reactapp/components/modals/MapLayer/MapLayer.js
+++ b/reactapp/components/modals/MapLayer/MapLayer.js
@@ -128,7 +128,7 @@ export function renameLayerInAttributeProps(attributeProps, oldName, newName) {
}
export const getLayerType = (sourceType) => {
- if (sourceType === "GeoTIFF") return "WebGLTile";
+ if (sourceType === "GeoTIFF" || sourceType === "Zarr") return "WebGLTile";
if (sourceType.includes("Vector")) return "VectorTileLayer";
if (sourceType.includes("Raster")) return "WebGLTile";
if (sourceType.includes("Tile")) return "TileLayer";
@@ -398,21 +398,26 @@ const MapLayerModal = ({
}
}
- if (sourceProps.type === "GeoTIFF") {
+ if (sourceProps.type === "GeoTIFF" || sourceProps.type === "Zarr") {
const { rampName, rampMin, rampMax } = sourceProps;
- const hasRamp =
- typeof rampName === "string" &&
- rampName.trim() !== "" &&
+ const hasRampName =
+ typeof rampName === "string" && rampName.trim() !== "";
+ const hasRange =
typeof rampMin === "string" &&
rampMin.trim() !== "" &&
typeof rampMax === "string" &&
rampMax.trim() !== "" &&
Number.isFinite(Number(rampMin)) &&
Number.isFinite(Number(rampMax));
- if (hasRamp) {
- const hasNodata = validSourceProps.sources.some(
- (s) => s?.nodata !== undefined && s.nodata !== "",
- );
+ if (hasRampName) {
+ // Zarr COGs always carry a -9999 nodata sentinel; GeoTIFF depends on
+ // whether the author set one on any source.
+ const hasNodata =
+ sourceProps.type === "Zarr"
+ ? true
+ : validSourceProps.sources.some(
+ (s) => s?.nodata !== undefined && s.nodata !== "",
+ );
const color = buildGeoTIFFStyleColor({
rampName,
rampMin,
@@ -421,8 +426,13 @@ const MapLayerModal = ({
});
mapConfiguration.configuration.style = { color };
mapConfiguration.configuration.props.source.rampName = rampName;
- mapConfiguration.configuration.props.source.rampMin = rampMin;
- mapConfiguration.configuration.props.source.rampMax = rampMax;
+ // Raw range styles raw band values; auto mode normalizes band 1 from stats.
+ mapConfiguration.configuration.props.source.props.normalize = !hasRange;
+ // Persist an explicit range only when set; empty = per-storm auto.
+ if (hasRange) {
+ mapConfiguration.configuration.props.source.rampMin = rampMin;
+ mapConfiguration.configuration.props.source.rampMax = rampMax;
+ }
}
} else if (style && style !== "{}") {
const apiResponse = await saveLayerJSON({
diff --git a/reactapp/components/modals/MapLayer/RampPicker.js b/reactapp/components/modals/MapLayer/RampPicker.js
index ef17a65d..42f2651d 100644
--- a/reactapp/components/modals/MapLayer/RampPicker.js
+++ b/reactapp/components/modals/MapLayer/RampPicker.js
@@ -32,12 +32,6 @@ const RampRow = styled.button`
}
`;
-const RampLabel = styled.span`
- min-width: 90px;
- font-size: 0.9rem;
- font-weight: 500;
-`;
-
const GradientSwatch = styled.span`
flex: 1;
height: 20px;
@@ -68,7 +62,6 @@ const RampPicker = ({ selectedRamp, onChange }) => {
$selected={isSelected}
onClick={() => onChange(name)}
>
- {name}
{
const isDynamic = !!findSelectOptionByValue(
@@ -94,6 +107,65 @@ const StylePane = ({
fetchAvailableFields();
}, [sourceProps, layerProps, uuid, dynamicMapLayers]);
+ useEffect(() => {
+ if (
+ (sourceProps.type === "GeoTIFF" || sourceProps.type === "Zarr") &&
+ !sourceProps.rampName &&
+ setSourceProps
+ ) {
+ setSourceProps((prev) => ({ ...prev, rampName: "turbo" }));
+ }
+ }, [sourceProps.type, sourceProps.rampName, setSourceProps]);
+
+ useEffect(() => {
+ if (sourceProps.type !== "GeoTIFF" || !setSourceProps || !geotiffUrl)
+ return;
+ // Auto-fill at most once per source URL, so clearing the fields sticks.
+ if (prefilledUrlRef.current === geotiffUrl) return;
+ const hasRange =
+ (sourceProps.rampMin ?? "") !== "" && (sourceProps.rampMax ?? "") !== "";
+ if (hasRange) {
+ prefilledUrlRef.current = geotiffUrl;
+ return;
+ }
+ const url = updateObjectWithVariableInputs({
+ args: { url: geotiffUrl },
+ variableInputs: variableInputValues ?? {},
+ }).url;
+ // Only fetch http(s) URLs; reject file:/blob:/javascript:/data:/protocol-relative.
+ if (!url || !/^https?:\/\//i.test(url)) return;
+ prefilledUrlRef.current = geotiffUrl;
+
+ let cancelled = false;
+ (async () => {
+ try {
+ const image = await (await fromUrl(url)).getImage();
+ const meta = image.getGDALMetadata(0);
+ const min = meta?.STATISTICS_MINIMUM;
+ const max = meta?.STATISTICS_MAXIMUM;
+ if (cancelled || min == null || max == null) return;
+ // Pre-fill the ramp range from the source's embedded statistics.
+ setSourceProps((prev) => ({
+ ...prev,
+ rampMin: String(Math.floor(Number(min))),
+ rampMax: String(Math.ceil(Number(max))),
+ }));
+ } catch {
+ // Leave blank (per-storm auto) if the file has no stats or can't load.
+ }
+ })();
+ return () => {
+ cancelled = true;
+ };
+ }, [
+ sourceProps.type,
+ sourceProps.rampMin,
+ sourceProps.rampMax,
+ geotiffUrl,
+ variableInputValues,
+ setSourceProps,
+ ]);
+
useEffect(() => {
const fetchJSON = async () => {
if (style.includes("/")) {
@@ -183,7 +255,7 @@ const StylePane = ({
}
}
- if (sourceProps.type === "GeoTIFF") {
+ if (sourceProps.type === "GeoTIFF" || sourceProps.type === "Zarr") {
const selectedRamp = sourceProps.rampName ?? null;
const rampMin = sourceProps.rampMin ?? "";
const rampMax = sourceProps.rampMax ?? "";
@@ -233,7 +305,12 @@ const StylePane = ({
);
}
- const supportedTypes = ["GeoJSON", "ESRI Feature Service", "PMTiles Vector"];
+ const supportedTypes = [
+ "GeoJSON",
+ "ESRI Feature Service",
+ "PMTiles Vector",
+ "GeoParquet",
+ ];
const isDynamicMapLayer = findSelectOptionByValue(
dynamicMapLayers,
sourceProps.type,
@@ -337,6 +414,9 @@ StylePane.propTypes = {
rampMin: PropTypes.string,
rampMax: PropTypes.string,
geojson: PropTypes.oneOfType([PropTypes.string, PropTypes.object]),
+ props: PropTypes.shape({
+ sources: PropTypes.arrayOf(PropTypes.shape({ url: PropTypes.string })),
+ }),
}),
setSourceProps: PropTypes.func,
layerProps: PropTypes.shape({
diff --git a/tethysapp/tethysdash/controllers.py b/tethysapp/tethysdash/controllers.py
index 47572975..926ac7f4 100644
--- a/tethysapp/tethysdash/controllers.py
+++ b/tethysapp/tethysdash/controllers.py
@@ -1,4 +1,4 @@
-from django.http import JsonResponse
+from django.http import HttpResponse, JsonResponse
import json
import os
import shutil
@@ -36,6 +36,20 @@
)
from tethysapp.tethysdash.exceptions import VisualizationError
from tethysapp.tethysdash.plugin_helpers import send_websocket_message
+from tethysapp.tethysdash.zarr_utils import (
+ ZarrCogError,
+ StoreOpenError,
+ open_store,
+ parse_byte_range,
+ read_cog,
+ read_metadata,
+)
+from tethysapp.tethysdash.url_safety import UnsafeURLError, validate_public_url
+from tethysapp.tethysdash.geoparquet import (
+ FileOpenError,
+ GeoParquetError,
+ read_geojson,
+)
from channels.generic.websocket import AsyncWebsocketConsumer
from tethys_sdk.routing import consumer
from asgiref.sync import sync_to_async
@@ -1036,3 +1050,168 @@ def download_json(request, app_workspace):
e, "Failed to download the json. Check server for logs."
)
return JsonResponse({"success": False, "message": message})
+
+
+def _cog_response(request, cog_bytes):
+ """Serve COG bytes, honoring an HTTP Range header with 206 partial content
+ so a COG reader can fetch byte ranges instead of the whole file."""
+ total = len(cog_bytes)
+ byte_range = parse_byte_range(request.META.get("HTTP_RANGE", ""), total)
+ if byte_range is not None:
+ start, end = byte_range
+ response = HttpResponse(
+ cog_bytes[start : end + 1], status=206, content_type="image/tiff"
+ )
+ response["Content-Range"] = f"bytes {start}-{end}/{total}"
+ else:
+ response = HttpResponse(cog_bytes, content_type="image/tiff")
+ response["Accept-Ranges"] = "bytes"
+ response["Cache-Control"] = "public, max-age=300"
+ return response
+
+
+@controller(url="tethysdash/zarr/cog", login_required=False)
+def zarr_cog(request):
+ """Stream one 2-D slice of a public Zarr store as a Cloud-Optimized GeoTIFF.
+
+ Reads the caller-supplied store on demand, slices the requested grid, and
+ returns a COG generated in memory (nothing is persisted). A frontend GeoTIFF
+ map layer consumes this URL directly.
+
+ Args:
+ request: Django HTTP request with query parameters:
+ - src: Public http(s)/s3 URL of the Zarr store (required)
+ - variable: Array name to read (required)
+ - index: Integer slice index along the leading dimension (default 0)
+ - mask_below: Optional float; cells <= it render transparent
+
+ Returns:
+ image/tiff COG (HttpResponse) on success; otherwise a JsonResponse with
+ an ``error`` and status 400 (bad request), 422 (unsafe URL), 500
+ (conversion failure), or 502 (store unreachable).
+ """
+ src = request.GET.get("src")
+ variable = request.GET.get("variable")
+ if not src:
+ return JsonResponse({"error": "missing required 'src' parameter"}, status=400)
+ if not variable:
+ return JsonResponse(
+ {"error": "missing required 'variable' parameter"}, status=400
+ )
+ try:
+ index = int(request.GET.get("index", "0"))
+ except (TypeError, ValueError):
+ return JsonResponse({"error": "'index' must be an integer"}, status=400)
+ mask_below = request.GET.get("mask_below")
+ if mask_below is not None:
+ try:
+ mask_below = float(mask_below)
+ except (TypeError, ValueError):
+ return JsonResponse({"error": "'mask_below' must be a number"}, status=400)
+
+ try:
+ validate_public_url(src)
+ except UnsafeURLError as e:
+ return JsonResponse({"error": str(e)}, status=422)
+
+ try:
+ cog_bytes = read_cog(src, variable, index, mask_below)
+ except StoreOpenError:
+ return JsonResponse({"error": "could not open store"}, status=502)
+ except ZarrCogError as e:
+ return JsonResponse({"error": str(e)}, status=400)
+ except Exception as e: # unexpected conversion failure
+ print(f"zarr->cog conversion failed: {e}")
+ return JsonResponse({"error": "failed to convert store"}, status=500)
+
+ return _cog_response(request, cog_bytes)
+
+
+@controller(url="tethysdash/zarr/meta", login_required=False)
+def zarr_meta(request):
+ """Return selectable metadata for a public Zarr store.
+
+ Lets the frontend populate a variable/slice selector without downloading any
+ raster data.
+
+ Args:
+ request: Django HTTP request with query parameters:
+ - src: Public http(s)/s3 URL of the Zarr store (required)
+ - variable: Reference array (optional; else the first griddable one)
+ - candidates: Comma-separated array names to probe on non-listable
+ stores (optional)
+ - label_var: 1-D array whose values label each slice (optional)
+
+ Returns:
+ JsonResponse with {variables, slice_count, slice_labels, crs, grid_shape,
+ extent} on success; otherwise an ``error`` with status 400 (bad request),
+ 422 (unsafe URL), or 502 (store unreachable).
+ """
+ src = request.GET.get("src")
+ if not src:
+ return JsonResponse({"error": "missing required 'src' parameter"}, status=400)
+
+ variable = request.GET.get("variable") or None
+ candidates = request.GET.get("candidates")
+ candidates = (
+ tuple(c.strip() for c in candidates.split(",") if c.strip())
+ if candidates
+ else None
+ )
+ label_var = request.GET.get("label_var") or None
+
+ try:
+ validate_public_url(src)
+ except UnsafeURLError as e:
+ return JsonResponse({"error": str(e)}, status=422)
+
+ try:
+ group = open_store(src)
+ except ZarrCogError:
+ return JsonResponse({"error": "could not open store"}, status=502)
+
+ try:
+ meta = read_metadata(
+ group, variable=variable, candidates=candidates, label_var=label_var
+ )
+ except ZarrCogError as e:
+ return JsonResponse({"error": str(e)}, status=400)
+
+ return JsonResponse(meta)
+
+
+@controller(url="tethysdash/geoparquet/geojson", login_required=False)
+def geoparquet_geojson(request):
+ """Read a public GeoParquet file and return it as GeoJSON (EPSG:4326).
+
+ The frontend GeoParquet vector source consumes this URL directly.
+
+ Args:
+ request: Django HTTP request with query parameters:
+ - src: Public http(s)/s3 URL of the GeoParquet file (required)
+
+ Returns:
+ application/geo+json (HttpResponse) on success; otherwise a JsonResponse
+ with an error and status 400 (bad request), 422 (unsafe URL), 500
+ (conversion failure), or 502 (file unreachable).
+ """
+ src = request.GET.get("src")
+ if not src:
+ return JsonResponse({"error": "missing required 'src' parameter"}, status=400)
+
+ try:
+ validate_public_url(src)
+ except UnsafeURLError as e:
+ return JsonResponse({"error": str(e)}, status=422)
+
+ try:
+ geojson = read_geojson(src)
+ except FileOpenError:
+ return JsonResponse({"error": "could not open geoparquet file"}, status=502)
+ except GeoParquetError as e:
+ return JsonResponse({"error": str(e)}, status=400)
+ except Exception as e: # unexpected conversion failure
+ print(f"geoparquet conversion failed: {e}")
+ return JsonResponse({"error": "failed to convert geoparquet"}, status=500)
+
+ return HttpResponse(geojson, content_type="application/geo+json")
diff --git a/tethysapp/tethysdash/geoparquet.py b/tethysapp/tethysdash/geoparquet.py
new file mode 100644
index 00000000..d77eb0b1
--- /dev/null
+++ b/tethysapp/tethysdash/geoparquet.py
@@ -0,0 +1,72 @@
+"""Django-free GeoParquet -> GeoJSON conversion.
+
+Reads a public GeoParquet file over HTTPS and returns it as a GeoJSON
+FeatureCollection reprojected to EPSG:4326 (the projection OpenLayers assumes for
+GeoJSON). Kept free of Django so the logic is unit-testable in isolation;
+``controllers.py`` wraps it with request handling.
+
+The file is read over HTTPS via fsspec (same approach as ``zarr_utils``); we
+avoid s3fs so no AWS stack or credentials are required for the common case.
+"""
+
+from __future__ import annotations
+
+import io
+import time
+
+import fsspec
+import geopandas as gpd
+
+# OpenLayers reads GeoJSON as EPSG:4326 by default, so normalize to it.
+TARGET_CRS = "EPSG:4326"
+
+
+class GeoParquetError(Exception):
+ """A GeoParquet file could not be read or converted."""
+
+
+class FileOpenError(GeoParquetError):
+ """The file URL could not be opened (network/URL error, not a parse error)."""
+
+
+def _retry(fn, attempts=3, base_delay=0.25):
+ """Call ``fn`` and retry on any exception, with linear backoff. The file is
+ read live over HTTP, so a single read occasionally fails transiently;
+ retrying re-issues it rather than failing the whole request."""
+ last = None
+ for i in range(attempts):
+ try:
+ return fn()
+ except Exception as e:
+ last = e
+ if i < attempts - 1:
+ time.sleep(base_delay * (i + 1))
+ raise last
+
+
+def _fetch_bytes(src):
+ """Read the whole file into memory (public URL, read-only)."""
+ with fsspec.open(src, "rb") as f:
+ return f.read()
+
+
+def read_geojson(src, target_crs=TARGET_CRS):
+ """Read a public GeoParquet file and return a GeoJSON FeatureCollection
+ string, reprojected to ``target_crs`` (default EPSG:4326).
+
+ Raises ``FileOpenError`` if the URL cannot be opened, or ``GeoParquetError``
+ if the bytes are not a valid GeoParquet.
+ """
+ try:
+ data = _retry(lambda: _fetch_bytes(src))
+ except Exception as e:
+ raise FileOpenError(f"could not open geoparquet file: {e}") from e
+
+ try:
+ gdf = gpd.read_parquet(io.BytesIO(data))
+ except Exception as e:
+ raise GeoParquetError(f"could not read geoparquet: {e}") from e
+
+ if target_crs and gdf.crs is not None:
+ gdf = gdf.to_crs(target_crs)
+ return gdf.to_json()
diff --git a/tethysapp/tethysdash/tests/integrated_tests/test_controllers.py b/tethysapp/tethysdash/tests/integrated_tests/test_controllers.py
index 98f78914..20597439 100644
--- a/tethysapp/tethysdash/tests/integrated_tests/test_controllers.py
+++ b/tethysapp/tethysdash/tests/integrated_tests/test_controllers.py
@@ -6,7 +6,7 @@
import os
import shutil
from django.conf import settings
-from django.test import override_settings
+from django.test import override_settings, RequestFactory
from datetime import datetime, timedelta
import types
from tethysapp.tethysdash.exceptions import VisualizationError
@@ -14,7 +14,13 @@
from tethysapp.tethysdash.controllers import (
VisualizationConsumer,
_get_main_bundle_path,
+ zarr_cog,
+ zarr_meta,
+ geoparquet_geojson,
)
+from tethysapp.tethysdash.zarr_utils import StoreOpenError, ZarrCogError
+from tethysapp.tethysdash.url_safety import UnsafeURLError
+from tethysapp.tethysdash.geoparquet import FileOpenError, GeoParquetError
from channels.layers import get_channel_layer
@@ -2523,3 +2529,196 @@ def test_get_main_bundle_path_returns_hashed_from_manifest():
manifest = json.dumps({"main.js": "main.abc123.js"})
with patch("builtins.open", mock_open(read_data=manifest)):
assert _get_main_bundle_path(request) == "frontend/main.abc123.js"
+
+
+# The zarr endpoints are exercised by calling the views directly with a
+# RequestFactory: they need no DB or URL routing, and the integrated
+# reverse()/client harness isn't available in every environment.
+def test_zarr_cog_happy_path(mocker):
+ mocker.patch("tethysapp.tethysdash.controllers.validate_public_url")
+ mock_read = mocker.patch(
+ "tethysapp.tethysdash.controllers.read_cog", return_value=b"COGBYTES"
+ )
+ request = RequestFactory().get(
+ "/zarr/cog", {"src": "https://x/store.zarr", "variable": "temp", "index": "2"}
+ )
+ response = zarr_cog(request)
+ assert response.status_code == 200
+ assert response["Content-Type"] == "image/tiff"
+ assert response.content == b"COGBYTES"
+ mock_read.assert_called_once_with("https://x/store.zarr", "temp", 2, None)
+
+
+def test_zarr_cog_range_returns_206(mocker):
+ mocker.patch("tethysapp.tethysdash.controllers.validate_public_url")
+ mocker.patch(
+ "tethysapp.tethysdash.controllers.read_cog", return_value=b"0123456789"
+ )
+ request = RequestFactory().get(
+ "/zarr/cog", {"src": "https://x", "variable": "t"}, HTTP_RANGE="bytes=2-5"
+ )
+ response = zarr_cog(request)
+ assert response.status_code == 206
+ assert response.content == b"2345"
+ assert response["Content-Range"] == "bytes 2-5/10"
+ assert response["Accept-Ranges"] == "bytes"
+
+
+def test_zarr_cog_requires_src_and_variable():
+ r1 = zarr_cog(RequestFactory().get("/zarr/cog", {"variable": "t"}))
+ assert r1.status_code == 400 and "src" in json.loads(r1.content)["error"]
+ r2 = zarr_cog(RequestFactory().get("/zarr/cog", {"src": "https://x"}))
+ assert r2.status_code == 400 and "variable" in json.loads(r2.content)["error"]
+
+
+def test_zarr_cog_rejects_bad_index_and_mask():
+ r1 = zarr_cog(
+ RequestFactory().get("/zarr/cog", {"src": "https://x", "variable": "t", "index": "abc"})
+ )
+ assert r1.status_code == 400 and "index" in json.loads(r1.content)["error"]
+ r2 = zarr_cog(
+ RequestFactory().get(
+ "/zarr/cog", {"src": "https://x", "variable": "t", "mask_below": "abc"}
+ )
+ )
+ assert r2.status_code == 400 and "mask_below" in json.loads(r2.content)["error"]
+
+
+def test_zarr_cog_mask_below_passed_through(mocker):
+ mocker.patch("tethysapp.tethysdash.controllers.validate_public_url")
+ mock_read = mocker.patch(
+ "tethysapp.tethysdash.controllers.read_cog", return_value=b"X"
+ )
+ request = RequestFactory().get(
+ "/zarr/cog", {"src": "https://x", "variable": "t", "mask_below": "0.5"}
+ )
+ zarr_cog(request)
+ mock_read.assert_called_once_with("https://x", "t", 0, 0.5)
+
+
+def test_zarr_cog_unsafe_url_422(mocker):
+ mocker.patch(
+ "tethysapp.tethysdash.controllers.validate_public_url",
+ side_effect=UnsafeURLError("bad host"),
+ )
+ request = RequestFactory().get(
+ "/zarr/cog", {"src": "http://169.254.169.254", "variable": "t"}
+ )
+ assert zarr_cog(request).status_code == 422
+
+
+def test_zarr_cog_store_open_error_502(mocker):
+ mocker.patch("tethysapp.tethysdash.controllers.validate_public_url")
+ mocker.patch(
+ "tethysapp.tethysdash.controllers.read_cog",
+ side_effect=StoreOpenError("unreachable"),
+ )
+ request = RequestFactory().get("/zarr/cog", {"src": "https://x", "variable": "t"})
+ assert zarr_cog(request).status_code == 502
+
+
+def test_zarr_cog_zarr_error_400(mocker):
+ mocker.patch("tethysapp.tethysdash.controllers.validate_public_url")
+ mocker.patch(
+ "tethysapp.tethysdash.controllers.read_cog",
+ side_effect=ZarrCogError("variable 'x' not found"),
+ )
+ request = RequestFactory().get("/zarr/cog", {"src": "https://x", "variable": "x"})
+ response = zarr_cog(request)
+ assert response.status_code == 400
+ assert "not found" in json.loads(response.content)["error"]
+
+
+def test_zarr_meta_happy_path_parses_params(mocker):
+ mocker.patch("tethysapp.tethysdash.controllers.validate_public_url")
+ mocker.patch("tethysapp.tethysdash.controllers.open_store", return_value=object())
+ meta = {
+ "variables": ["temp", "coord"],
+ "slice_count": 3,
+ "slice_labels": ["0", "1", "2"],
+ "crs": "EPSG:3857",
+ "grid_shape": [4, 5],
+ "extent": [0, 0, 1, 1],
+ }
+ mock_meta = mocker.patch(
+ "tethysapp.tethysdash.controllers.read_metadata", return_value=meta
+ )
+ request = RequestFactory().get(
+ "/zarr/meta",
+ {
+ "src": "https://x",
+ "variable": "temp",
+ "candidates": "temp, coord",
+ "label_var": "time",
+ },
+ )
+ response = zarr_meta(request)
+ assert response.status_code == 200
+ assert json.loads(response.content) == meta
+ _, kwargs = mock_meta.call_args
+ assert kwargs["variable"] == "temp"
+ assert kwargs["candidates"] == ("temp", "coord")
+ assert kwargs["label_var"] == "time"
+
+
+def test_zarr_meta_unsafe_url_422(mocker):
+ mocker.patch(
+ "tethysapp.tethysdash.controllers.validate_public_url",
+ side_effect=UnsafeURLError("bad"),
+ )
+ request = RequestFactory().get("/zarr/meta", {"src": "http://169.254.169.254"})
+ assert zarr_meta(request).status_code == 422
+
+
+def test_geoparquet_geojson_happy_path(mocker):
+ mocker.patch("tethysapp.tethysdash.controllers.validate_public_url")
+ fc = '{"type": "FeatureCollection", "features": []}'
+ mock_read = mocker.patch(
+ "tethysapp.tethysdash.controllers.read_geojson", return_value=fc
+ )
+ request = RequestFactory().get(
+ "/geoparquet/geojson", {"src": "https://x/data.parquet"}
+ )
+ response = geoparquet_geojson(request)
+ assert response.status_code == 200
+ assert response["Content-Type"] == "application/geo+json"
+ assert response.content.decode() == fc
+ mock_read.assert_called_once_with("https://x/data.parquet")
+
+
+def test_geoparquet_geojson_requires_src():
+ r = geoparquet_geojson(RequestFactory().get("/geoparquet/geojson"))
+ assert r.status_code == 400 and "src" in json.loads(r.content)["error"]
+
+
+def test_geoparquet_geojson_unsafe_url_422(mocker):
+ mocker.patch(
+ "tethysapp.tethysdash.controllers.validate_public_url",
+ side_effect=UnsafeURLError("bad host"),
+ )
+ request = RequestFactory().get(
+ "/geoparquet/geojson", {"src": "http://169.254.169.254"}
+ )
+ assert geoparquet_geojson(request).status_code == 422
+
+
+def test_geoparquet_geojson_file_open_error_502(mocker):
+ mocker.patch("tethysapp.tethysdash.controllers.validate_public_url")
+ mocker.patch(
+ "tethysapp.tethysdash.controllers.read_geojson",
+ side_effect=FileOpenError("unreachable"),
+ )
+ request = RequestFactory().get("/geoparquet/geojson", {"src": "https://x"})
+ assert geoparquet_geojson(request).status_code == 502
+
+
+def test_geoparquet_geojson_parse_error_400(mocker):
+ mocker.patch("tethysapp.tethysdash.controllers.validate_public_url")
+ mocker.patch(
+ "tethysapp.tethysdash.controllers.read_geojson",
+ side_effect=GeoParquetError("could not read geoparquet: boom"),
+ )
+ request = RequestFactory().get("/geoparquet/geojson", {"src": "https://x"})
+ response = geoparquet_geojson(request)
+ assert response.status_code == 400
+ assert "could not read" in json.loads(response.content)["error"]
diff --git a/tethysapp/tethysdash/tests/unit_tests/test_geoparquet.py b/tethysapp/tethysdash/tests/unit_tests/test_geoparquet.py
new file mode 100644
index 00000000..4b034d3a
--- /dev/null
+++ b/tethysapp/tethysdash/tests/unit_tests/test_geoparquet.py
@@ -0,0 +1,90 @@
+"""Unit tests for the Django-free GeoParquet->GeoJSON conversion logic.
+
+Hermetic: each test writes a small GeoParquet to a temp file (no network).
+"""
+
+import json
+
+import geopandas as gpd
+import pytest
+from shapely.geometry import Point
+
+import tethysapp.tethysdash.geoparquet as gp
+from tethysapp.tethysdash.geoparquet import (
+ FileOpenError,
+ GeoParquetError,
+ read_geojson,
+)
+
+
+def _write_parquet(path, gdf):
+ gdf.to_parquet(path)
+ return str(path)
+
+
+def test_read_geojson_returns_feature_collection(tmp_path):
+ gdf = gpd.GeoDataFrame(
+ {"name": ["a", "b"], "val": [1, 2]},
+ geometry=[Point(0, 0), Point(10, 20)],
+ crs="EPSG:4326",
+ )
+ fc = json.loads(read_geojson(_write_parquet(tmp_path / "pts.parquet", gdf)))
+ assert fc["type"] == "FeatureCollection"
+ assert len(fc["features"]) == 2
+ props = fc["features"][0]["properties"]
+ assert props["name"] == "a" and props["val"] == 1
+ assert fc["features"][0]["geometry"]["type"] == "Point"
+
+
+def test_read_geojson_reprojects_to_4326(tmp_path):
+ # 1113194.9 m easting in Web Mercator is ~10 deg longitude at the equator.
+ gdf = gpd.GeoDataFrame(
+ {"n": [1]}, geometry=[Point(1113194.9, 0)], crs="EPSG:3857"
+ )
+ fc = json.loads(read_geojson(_write_parquet(tmp_path / "m.parquet", gdf)))
+ lon, lat = fc["features"][0]["geometry"]["coordinates"]
+ assert lon == pytest.approx(10.0, abs=1e-3)
+ assert lat == pytest.approx(0.0, abs=1e-6)
+
+
+def test_read_geojson_without_crs_is_not_reprojected(tmp_path):
+ gdf = gpd.GeoDataFrame({"n": [1]}, geometry=[Point(5, 5)], crs=None)
+ fc = json.loads(read_geojson(_write_parquet(tmp_path / "nocrs.parquet", gdf)))
+ assert fc["features"][0]["geometry"]["coordinates"] == [5.0, 5.0]
+
+
+def test_unreadable_file_raises_file_open_error(tmp_path, monkeypatch):
+ monkeypatch.setattr(gp.time, "sleep", lambda _s: None)
+ with pytest.raises(FileOpenError, match="could not open"):
+ read_geojson(str(tmp_path / "does_not_exist.parquet"))
+
+
+def test_invalid_parquet_raises_geoparquet_error(tmp_path):
+ bad = tmp_path / "bad.parquet"
+ bad.write_bytes(b"not a parquet file")
+ with pytest.raises(GeoParquetError, match="could not read"):
+ read_geojson(str(bad))
+
+
+def test_retry_recovers_after_transient_failures(monkeypatch):
+ monkeypatch.setattr(gp.time, "sleep", lambda _s: None)
+ calls = {"n": 0}
+
+ def flaky():
+ calls["n"] += 1
+ if calls["n"] < 3:
+ raise ConnectionError("transient")
+ return "ok"
+
+ assert gp._retry(flaky) == "ok"
+ assert calls["n"] == 3
+
+
+def test_retry_raises_after_exhausting_attempts(monkeypatch):
+ monkeypatch.setattr(gp.time, "sleep", lambda _s: None)
+
+ def always_fail():
+ raise ConnectionError("boom")
+
+ with pytest.raises(ConnectionError, match="boom"):
+ gp._retry(always_fail, attempts=2)
diff --git a/tethysapp/tethysdash/tests/unit_tests/test_url_safety.py b/tethysapp/tethysdash/tests/unit_tests/test_url_safety.py
new file mode 100644
index 00000000..e0593657
--- /dev/null
+++ b/tethysapp/tethysdash/tests/unit_tests/test_url_safety.py
@@ -0,0 +1,59 @@
+"""Unit tests for the SSRF URL guard. DNS resolution is stubbed so tests are
+hermetic and deterministic."""
+
+import socket
+
+import pytest
+
+from tethysapp.tethysdash import url_safety
+from tethysapp.tethysdash.url_safety import UnsafeURLError, validate_public_url
+
+
+def _stub_dns(monkeypatch, ip):
+ def fake_getaddrinfo(host, *args, **kwargs):
+ return [(socket.AF_INET, socket.SOCK_STREAM, 6, "", (ip, 0))]
+
+ monkeypatch.setattr(url_safety.socket, "getaddrinfo", fake_getaddrinfo)
+
+
+def test_public_host_is_allowed(monkeypatch):
+ _stub_dns(monkeypatch, "93.184.216.34")
+ url = "https://example.s3.us-east-1.amazonaws.com/floodmaps/"
+ assert validate_public_url(url) == url
+
+
+@pytest.mark.parametrize(
+ "ip",
+ [
+ "127.0.0.1", # loopback
+ "10.0.0.5", # private
+ "172.16.0.1", # private
+ "192.168.1.1", # private
+ "169.254.169.254", # link-local / cloud metadata
+ "0.0.0.0", # unspecified
+ ],
+)
+def test_blocked_addresses_are_rejected(monkeypatch, ip):
+ _stub_dns(monkeypatch, ip)
+ with pytest.raises(UnsafeURLError, match="blocked address"):
+ validate_public_url("https://evil.example.com/x")
+
+
+@pytest.mark.parametrize("url", ["file:///etc/passwd", "s3://bucket/key", "ftp://h/x"])
+def test_disallowed_schemes_are_rejected(url):
+ with pytest.raises(UnsafeURLError, match="not allowed"):
+ validate_public_url(url)
+
+
+def test_missing_host_is_rejected():
+ with pytest.raises(UnsafeURLError, match="no host"):
+ validate_public_url("https://")
+
+
+def test_unresolvable_host_is_rejected(monkeypatch):
+ def boom(host, *args, **kwargs):
+ raise socket.gaierror("nope")
+
+ monkeypatch.setattr(url_safety.socket, "getaddrinfo", boom)
+ with pytest.raises(UnsafeURLError, match="could not resolve"):
+ validate_public_url("https://does-not-exist.example/x")
diff --git a/tethysapp/tethysdash/tests/unit_tests/test_zarr_utils.py b/tethysapp/tethysdash/tests/unit_tests/test_zarr_utils.py
new file mode 100644
index 00000000..8513a1f7
--- /dev/null
+++ b/tethysapp/tethysdash/tests/unit_tests/test_zarr_utils.py
@@ -0,0 +1,220 @@
+"""Unit tests for the Django-free zarr->COG conversion logic.
+
+Hermetic: each test builds a fresh in-memory Zarr v3 group (no network, no S3).
+"""
+
+import numpy as np
+import pytest
+import zarr
+from rasterio.io import MemoryFile
+
+import tethysapp.tethysdash.zarr_utils as zu
+from tethysapp.tethysdash.zarr_utils import (
+ NODATA,
+ ZarrCogError,
+ build_cog,
+ read_metadata,
+)
+
+CRS = "EPSG:3857"
+TRANSFORM = [5.0, 0.0, -100.0, 0.0, -5.0, 200.0] # 5 m pixels, origin (-100, 200)
+
+
+def _make_group(threshold=0.05, source_nodata=NODATA):
+ """Build an in-memory Zarr v3 group: 3 slices, 4x5 grid.
+
+ ``threshold``/``source_nodata`` default to a flood-style store that declares
+ masking; pass ``None`` to build a plain store that declares neither.
+ """
+ g = zarr.open_group(store=zarr.storage.MemoryStore(), mode="w")
+ g.attrs["crs"] = CRS
+ g.attrs["transform"] = TRANSFORM
+ if threshold is not None:
+ g.attrs["extent_threshold_m"] = threshold
+ if source_nodata is not None:
+ g.attrs["source_nodata"] = source_nodata
+ data = np.zeros((3, 4, 5), dtype="float32")
+ data[0, 0, 0] = 2.5 # wet
+ data[0, 1, 1] = 0.01 # below a 0.05 threshold
+ data[0, 2, 2] = NODATA # source nodata
+ arr = g.create_array("depth", shape=(3, 4, 5), dtype="float32")
+ arr[:] = data
+ return g
+
+
+def _make_2d_group(name, values):
+ g = zarr.open_group(store=zarr.storage.MemoryStore(), mode="w")
+ g.attrs["crs"] = CRS
+ g.attrs["transform"] = TRANSFORM
+ arr = g.create_array(name, shape=values.shape, dtype="float32")
+ arr[:] = values
+ return g
+
+
+def test_build_cog_is_valid_georeferenced_cog():
+ cog_bytes = build_cog(_make_group(), "depth", 0)
+ with MemoryFile(cog_bytes) as mem, mem.open() as ds:
+ assert ds.crs.to_string() == CRS
+ assert (ds.width, ds.height) == (5, 4)
+ assert ds.transform.a == 5.0 and ds.transform.e == -5.0
+ assert ds.nodata == NODATA
+ band = ds.read(1)
+ assert band[0, 0] == pytest.approx(2.5) # wet preserved
+ assert band[1, 1] == NODATA # below threshold -> nodata
+ assert band[2, 2] == NODATA # source nodata -> nodata
+
+
+def test_build_cog_without_declared_masking_keeps_all_values():
+ # fix #1: a store that declares no masking must not drop zeros/negatives --
+ # the old dry-cell default (threshold 0.0) would have erased them.
+ g = _make_group(threshold=None, source_nodata=None)
+ vals = np.full((3, 4, 5), -3.0, dtype="float32")
+ vals[0, 0, 0] = 5.0
+ vals[0, 1, 1] = 0.0
+ g["depth"][:] = vals
+ cog_bytes = build_cog(g, "depth", 0)
+ with MemoryFile(cog_bytes) as mem, mem.open() as ds:
+ band = ds.read(1)
+ assert band[0, 0] == pytest.approx(5.0)
+ assert band[1, 1] == pytest.approx(0.0) # zero survives
+ assert band[2, 0] == pytest.approx(-3.0) # negative survives
+ assert not np.any(band == NODATA)
+
+
+def test_explicit_mask_below_masks_without_store_attrs():
+ g = _make_group(threshold=None, source_nodata=None)
+ cog_bytes = build_cog(g, "depth", 0, mask_below=0.05)
+ with MemoryFile(cog_bytes) as mem, mem.open() as ds:
+ band = ds.read(1)
+ assert band[0, 0] == pytest.approx(2.5)
+ assert band[1, 1] == NODATA # 0.01 <= 0.05
+
+
+def test_index_out_of_range_raises():
+ with pytest.raises(ZarrCogError, match="out of range"):
+ build_cog(_make_group(), "depth", 99)
+
+
+def test_unknown_variable_raises():
+ with pytest.raises(ZarrCogError, match="not found"):
+ build_cog(_make_group(), "nope", 0)
+
+
+def test_missing_georeference_attrs_raises():
+ g = _make_group()
+ del g.attrs["transform"]
+ with pytest.raises(ZarrCogError, match="georeference"):
+ build_cog(g, "depth", 0)
+
+
+def test_build_cog_handles_2d_grid():
+ # fix #3: a plain [y, x] grid is a single slice at index 0.
+ g = _make_2d_group("elevation", np.arange(20, dtype="float32").reshape(4, 5))
+ cog_bytes = build_cog(g, "elevation", 0)
+ with MemoryFile(cog_bytes) as mem, mem.open() as ds:
+ assert (ds.width, ds.height) == (5, 4)
+ assert ds.read(1)[0, 1] == pytest.approx(1.0)
+
+
+def test_build_cog_2d_grid_rejects_nonzero_index():
+ g = _make_2d_group("elevation", np.zeros((4, 5), dtype="float32"))
+ with pytest.raises(ZarrCogError, match="single slice"):
+ build_cog(g, "elevation", 1)
+
+
+def test_build_cog_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")
+ with pytest.raises(ZarrCogError, match=r"2D .* or 3D"):
+ build_cog(g, "cube", 0)
+
+
+def test_read_metadata_reports_slices_and_extent():
+ meta = read_metadata(_make_group())
+ assert meta["variables"] == ["depth"]
+ assert meta["slice_count"] == 3
+ assert meta["crs"] == CRS
+ assert meta["grid_shape"] == [4, 5]
+ # extent = [minx, miny, maxx, maxy]; 5 cols x 5m = 25 wide, 4 rows x 5m = 20 tall
+ assert meta["extent"] == [-100.0, 180.0, -75.0, 200.0]
+ # no label_var -> labels fall back to slice indices
+ assert meta["slice_labels"] == ["0", "1", "2"]
+
+
+def test_read_metadata_uses_label_var_when_present():
+ g = _make_group()
+ mag = g.create_array("magnitude_mm", shape=(3,), dtype="float32")
+ mag[:] = np.array([10.0, 20.0, 30.0], dtype="float32")
+ meta = read_metadata(g, label_var="magnitude_mm")
+ assert meta["slice_labels"] == ["10", "20", "30"]
+ assert set(meta["variables"]) == {"depth", "magnitude_mm"}
+
+
+def test_read_metadata_handles_2d_grid():
+ meta = read_metadata(_make_2d_group("elevation", np.zeros((4, 5), dtype="float32")))
+ assert meta["slice_count"] == 1
+ assert meta["grid_shape"] == [4, 5]
+
+
+def test_read_metadata_discovers_via_candidates_when_unlisted(monkeypatch):
+ # fix #2: non-listable stores (HTTP) fall back to caller candidates rather
+ # than a hardcoded flood list; without either, the error is explicit.
+ g = _make_group()
+ monkeypatch.setattr(type(g), "array_keys", lambda self: [])
+ meta = read_metadata(g, candidates=("depth",))
+ assert meta["variables"] == ["depth"]
+ with pytest.raises(ZarrCogError, match="could not determine"):
+ read_metadata(g)
+
+
+def test_retry_recovers_after_transient_failures(monkeypatch):
+ monkeypatch.setattr(zu.time, "sleep", lambda _s: None)
+ calls = {"n": 0}
+
+ def flaky():
+ calls["n"] += 1
+ if calls["n"] < 3:
+ raise ConnectionError("transient")
+ return "ok"
+
+ assert zu._retry(flaky) == "ok"
+ assert calls["n"] == 3
+
+
+def test_retry_raises_after_exhausting_attempts(monkeypatch):
+ monkeypatch.setattr(zu.time, "sleep", lambda _s: None)
+
+ def always_fail():
+ raise ConnectionError("boom")
+
+ with pytest.raises(ConnectionError, match="boom"):
+ zu._retry(always_fail, attempts=2)
+
+
+@pytest.mark.parametrize(
+ "header,total,expected",
+ [
+ ("", 100, None),
+ (None, 100, None),
+ ("items=0-10", 100, None), # wrong unit
+ ("bytes=abc", 100, None), # malformed
+ ("bytes=0-49", 100, (0, 49)),
+ ("bytes=0-65536", 100, (0, 99)), # end clamped to EOF
+ ("bytes=50-", 100, (50, 99)), # open-ended
+ ("bytes=-10", 100, (90, 99)), # suffix
+ ("bytes=200-300", 100, None), # start past EOF
+ ],
+)
+def test_parse_byte_range(header, total, expected):
+ assert zu.parse_byte_range(header, total) == expected
+
+
+def test_build_cog_embeds_band_statistics():
+ cog_bytes = build_cog(_make_group(), "depth", 0)
+ with MemoryFile(cog_bytes) as mem, mem.open() as ds:
+ tags = ds.tags(1)
+ # 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)
diff --git a/tethysapp/tethysdash/url_safety.py b/tethysapp/tethysdash/url_safety.py
new file mode 100644
index 00000000..ad2fce4f
--- /dev/null
+++ b/tethysapp/tethysdash/url_safety.py
@@ -0,0 +1,50 @@
+"""Server-side URL validation (SSRF guard).
+
+The floodmap endpoints fetch a caller-supplied URL server-side, so we must
+reject anything that could reach internal/loopback/link-local/cloud-metadata
+addresses. Only public http/https hosts are allowed.
+"""
+
+import ipaddress
+import socket
+from urllib.parse import urlparse
+
+ALLOWED_SCHEMES = ("http", "https")
+
+
+class UnsafeURLError(Exception):
+ """A URL was rejected as unsafe for the server to fetch."""
+
+
+def _resolve_ips(host):
+ infos = socket.getaddrinfo(host, None)
+ return {ipaddress.ip_address(info[4][0]) for info in infos}
+
+
+def _is_blocked(ip):
+ return (
+ ip.is_private
+ or ip.is_loopback
+ or ip.is_link_local # includes 169.254.169.254 cloud metadata
+ or ip.is_reserved
+ or ip.is_multicast
+ or ip.is_unspecified
+ )
+
+
+def validate_public_url(url):
+ """Return ``url`` if safe to fetch server-side, else raise UnsafeURLError."""
+ parsed = urlparse(url)
+ if parsed.scheme not in ALLOWED_SCHEMES:
+ raise UnsafeURLError(f"scheme '{parsed.scheme}' is not allowed")
+ host = parsed.hostname
+ if not host:
+ raise UnsafeURLError("URL has no host")
+ try:
+ ips = _resolve_ips(host)
+ except socket.gaierror as e:
+ raise UnsafeURLError(f"could not resolve host '{host}'") from e
+ for ip in ips:
+ if _is_blocked(ip):
+ raise UnsafeURLError(f"host '{host}' resolves to blocked address {ip}")
+ return url
diff --git a/tethysapp/tethysdash/zarr_utils.py b/tethysapp/tethysdash/zarr_utils.py
new file mode 100644
index 00000000..b2361ebc
--- /dev/null
+++ b/tethysapp/tethysdash/zarr_utils.py
@@ -0,0 +1,259 @@
+"""Django-free zarr -> Cloud-Optimized GeoTIFF conversion.
+
+Opens a public Zarr store, slices one 2-D grid from a variable (a plain
+``[y, x]`` grid, or one ``index`` along a leading ``[n, y, x]`` dimension), and
+converts it to a COG entirely in memory -- nothing is written to disk or S3.
+Kept free of Django so the conversion logic is unit-testable in isolation;
+``controllers.py`` wraps these functions with request handling.
+
+The store is read over HTTPS via fsspec (see EF5_FLOODMAPS_SPEC.md 3.4); we
+intentionally avoid s3fs so no AWS stack or credentials are required.
+"""
+
+from __future__ import annotations
+
+import time
+from functools import lru_cache
+
+import numpy as np
+import zarr
+from zarr.storage import FsspecStore
+from rasterio.io import MemoryFile
+from rasterio.transform import Affine
+from rio_cogeo.cogeo import cog_translate
+from rio_cogeo.profiles import cog_profiles
+
+# Masked cells get this concrete nodata value. We deliberately avoid NaN:
+# OpenLayers masks cells with `value == nodata`, and `NaN == NaN` is always
+# false, so NaN cells would never render transparent. A negative sentinel suits
+# the non-negative grids this is typically used with.
+NODATA = -9999.0
+
+
+class ZarrCogError(Exception):
+ """A zarr store could not be read or converted to a COG."""
+
+
+class StoreOpenError(ZarrCogError):
+ """The store URL could not be opened (network/URL error, not a read error)."""
+
+
+def _retry(fn, attempts=3, base_delay=0.25):
+ """Call ``fn`` and retry on any exception, with linear backoff. The remote
+ store is read live over HTTP, so a single range read occasionally fails
+ transiently; retrying re-issues it rather than failing the whole request."""
+ last = None
+ for i in range(attempts):
+ try:
+ return fn()
+ except Exception as e:
+ last = e
+ if i < attempts - 1:
+ time.sleep(base_delay * (i + 1))
+ raise last
+
+
+def open_store(src):
+ """Open the Zarr group at ``src`` (a public https URL) read-only."""
+ try:
+ return _retry(lambda: zarr.open_group(FsspecStore.from_url(src), mode="r"))
+ except Exception as e: # surface a clean message to the API layer
+ raise StoreOpenError(f"could not open zarr store: {e}") from e
+
+
+def _get_array(group, name):
+ """Return array ``name`` by direct access, or None. Direct access works over
+ HTTP-backed stores, which cannot enumerate their members."""
+ try:
+ return group[name]
+ except KeyError:
+ return None
+
+
+def _discover_variables(group, candidates=None):
+ """Array names. Lists the store when it can be enumerated; otherwise probes
+ the caller-supplied ``candidates`` (HTTP-backed stores can't be listed)."""
+ names = list(group.array_keys())
+ if names:
+ return names
+ if candidates:
+ return [n for n in candidates if _get_array(group, n) is not None]
+ return []
+
+
+def _slice_labels(group, n_slices, label_var=None):
+ """Per-slice labels for a selector: values of the 1-D ``label_var`` array
+ when present and matching the slice count, else the slice index string."""
+ if label_var is not None:
+ arr = _get_array(group, label_var)
+ if arr is not None and arr.ndim == 1 and int(arr.shape[0]) == n_slices:
+ return [f"{float(v):g}" for v in np.asarray(arr[:])]
+ return [str(i) for i in range(n_slices)]
+
+
+def _grid_dims(shape):
+ """(n_slices, height, width) for a 2-D ``[y, x]`` or 3-D ``[n, y, x]`` array."""
+ if len(shape) == 2:
+ return 1, int(shape[0]), int(shape[1])
+ if len(shape) == 3:
+ return int(shape[0]), int(shape[1]), int(shape[2])
+ raise ZarrCogError(
+ f"expected a 2D [y, x] or 3D [n, y, x] array, got shape {tuple(shape)}"
+ )
+
+
+def _is_griddable(group, name):
+ """True when array ``name`` is a 2-D or 3-D grid (not a 1-D coord/label)."""
+ arr = _get_array(group, name)
+ return arr is not None and arr.ndim in (2, 3)
+
+
+def read_metadata(group, variable=None, candidates=None, label_var=None):
+ """Return selectable metadata: variables, slice count, crs, extent, grid.
+
+ ``variable`` picks the reference array (else the first griddable one);
+ ``candidates`` seeds discovery for non-listable stores; ``label_var`` names a
+ 1-D array whose values label each slice.
+ """
+ variables = _discover_variables(group, candidates)
+ if variable is not None:
+ ref_name = variable
+ else:
+ # Auto-pick the first griddable array; skip 1-D coordinate/label arrays
+ # a store may also expose. List order is hash-dependent, so filtering by
+ # shape keeps selection deterministic.
+ ref_name = next((n for n in variables if _is_griddable(group, n)), None)
+ if ref_name is None:
+ raise ZarrCogError("could not determine a griddable variable; pass `variable`")
+ ref = _get_array(group, ref_name)
+ if ref is None:
+ raise ZarrCogError(f"variable '{ref_name}' not found")
+ attrs = dict(group.attrs)
+ if "transform" not in attrs:
+ raise ZarrCogError("store missing 'transform' attr; cannot georeference")
+ n_slices, height, width = _grid_dims(ref.shape)
+ transform = Affine(*attrs["transform"])
+ minx, top = transform.c, transform.f
+ maxx = minx + transform.a * width
+ bottom = top + transform.e * height # e is negative -> bottom < top
+ return {
+ "variables": variables,
+ "slice_count": n_slices,
+ "slice_labels": _slice_labels(group, n_slices, label_var),
+ "crs": attrs.get("crs"),
+ "grid_shape": [height, width],
+ "extent": [minx, min(top, bottom), maxx, max(top, bottom)],
+ }
+
+
+def build_cog(
+ group, variable, index=0, *, nodata=NODATA, mask_below=None, source_nodata=None
+):
+ """Slice one 2-D grid from ``variable`` and return COG bytes (in memory).
+
+ The slice is a plain ``[y, x]`` array, or ``index`` along a leading
+ ``[n, y, x]`` dimension. Cells are masked to ``nodata`` (rendered
+ transparent) only when asked: ``mask_below`` masks values ``<=`` it and
+ ``source_nodata`` masks an upstream sentinel. Both default to the store's
+ ``extent_threshold_m`` / ``source_nodata`` attrs when present, so a store can
+ declare its own masking; a store that declares neither is left untouched.
+ """
+ arr_z = _get_array(group, variable)
+ if arr_z is None:
+ raise ZarrCogError(f"variable '{variable}' not found")
+
+ attrs = dict(group.attrs)
+ if "crs" not in attrs or "transform" not in attrs:
+ raise ZarrCogError("store missing 'crs'/'transform' attrs; cannot georeference")
+ crs = attrs["crs"]
+ transform = Affine(*attrs["transform"])
+ if mask_below is None:
+ mask_below = attrs.get("extent_threshold_m")
+ if source_nodata is None:
+ source_nodata = attrs.get("source_nodata")
+
+ ndim = len(arr_z.shape)
+ if ndim == 2:
+ if index != 0:
+ raise ZarrCogError("2D array has a single slice; index must be 0")
+ arr = _retry(lambda: np.asarray(arr_z, dtype="float32"))
+ elif ndim == 3:
+ n = int(arr_z.shape[0])
+ if not (0 <= index < n):
+ raise ZarrCogError(f"index {index} out of range 0..{n - 1}")
+ arr = _retry(lambda: np.asarray(arr_z[index], dtype="float32"))
+ else:
+ raise ZarrCogError(
+ f"expected a 2D [y, x] or 3D [n, y, x] array, got shape {tuple(arr_z.shape)}"
+ )
+
+ mask = None
+ if mask_below is not None:
+ mask = arr <= float(mask_below)
+ if source_nodata is not None:
+ sn = arr == np.float32(source_nodata)
+ mask = sn if mask is None else (mask | sn)
+ if mask is not None:
+ arr = np.where(mask, nodata, arr).astype("float32")
+
+ # Embed the slice's value range as band statistics so the map layer can
+ # normalize the color ramp to this slice (OpenLayers reads STATISTICS_*).
+ valid = arr[arr != nodata]
+ vmin = float(valid.min()) if valid.size else 0.0
+ vmax = float(valid.max()) if valid.size else 0.0
+
+ profile = {
+ "driver": "GTiff", "dtype": "float32", "count": 1,
+ "height": arr.shape[0], "width": arr.shape[1],
+ "crs": crs, "transform": transform, "nodata": nodata,
+ }
+ with MemoryFile() as src_mem:
+ with src_mem.open(**profile) as src_ds:
+ src_ds.write(arr, 1)
+ src_ds.update_tags(
+ 1, STATISTICS_MINIMUM=repr(vmin), STATISTICS_MAXIMUM=repr(vmax)
+ )
+ with MemoryFile() as dst_mem:
+ cog_translate(
+ src_mem.name, dst_mem.name, cog_profiles.get("deflate"),
+ in_memory=True, quiet=True, forward_band_tags=True,
+ )
+ return dst_mem.read()
+
+
+@lru_cache(maxsize=64)
+def read_cog(src, variable, index=0, mask_below=None):
+ """COG bytes for one slice, cached by (src, variable, index, mask_below). A
+ map layer reads a COG via several HTTP range requests; caching means those
+ don't each re-open the store and rebuild the file. Masking otherwise follows
+ the store's declared attrs (see ``build_cog``)."""
+ return build_cog(open_store(src), variable, index, mask_below=mask_below)
+
+
+def parse_byte_range(range_header, total):
+ """Parse an HTTP ``Range`` header against a ``total``-byte payload.
+
+ Returns an inclusive ``(start, end)`` clamped to the payload, or ``None``
+ when there is no usable byte range (the caller then sends the full body).
+ """
+ if not range_header or not range_header.startswith("bytes="):
+ return None
+ spec = range_header[len("bytes=") :].split(",", 1)[0].strip()
+ start_s, sep, end_s = spec.partition("-")
+ if not sep:
+ return None
+ try:
+ if start_s == "": # suffix range: final N bytes
+ n = int(end_s)
+ if n <= 0:
+ return None
+ start, end = max(0, total - n), total - 1
+ else:
+ start = int(start_s)
+ end = int(end_s) if end_s else total - 1
+ except ValueError:
+ return None
+ end = min(end, total - 1)
+ if start < 0 or start > end or start >= total:
+ return None
+ return (start, end)