Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 33 additions & 0 deletions docs/source/maps/source_tab.rst
Original file line number Diff line number Diff line change
Expand Up @@ -305,6 +305,39 @@ The Zarr source renders a raster layer from a public `Zarr <https://zarr.dev/>`_

------------------------------------------------------------------------------------------------------------------------

++++++++++
GeoPackage
++++++++++

The GeoPackage source renders **vector** layers from a public `GeoPackage <https://www.geopackage.org/>`_ (``.gpkg``) file. A GeoPackage is an OGC SQLite database that may hold several feature tables, so you supply a file URL **and** the table (layer) name — one map layer per table. TethysDash reads the file entirely in the browser and reprojects it to the map projection, so no server-side conversion is involved. Layers are styled and queried like a GeoJSON layer.

**Layer Properties:**
- **url:** (required) Public URL of the GeoPackage file (an ``https`` URL, or an ``s3://`` URL which is translated to its public ``https`` form). The host must allow cross-origin (CORS) reads.
- **layer:** (required) Name of the feature table within the GeoPackage to render.

.. note::
The file is downloaded and parsed in the browser, so this suits moderate feature counts; very large tables may be slow to render. Feature tables in projected CRSs (e.g. UTM) are reprojected automatically; other custom CRSs may not be supported.

**Example JSON Configuration:**

::

{
"type": "VectorLayer",
"props": {
"name": "Buildings",
"source": {
"type": "GeoPackage",
"props": {
"url": "https://example.com/flood_event.gpkg",
"layer": "buildings"
}
}
}
}

------------------------------------------------------------------------------------------------------------------------

+++++++++++++
Custom Layers
+++++++++++++
Expand Down
20 changes: 19 additions & 1 deletion package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

6 changes: 4 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -50,11 +50,12 @@
"html-react-parser": "^5.1.18",
"json5": "^2.2.3",
"ol": "10.4.0",
"ol-load-geopackage": "^2.3.1",
"ol-mapbox-style": "12.4.0",
"ol-pmtiles": "^2.0.2",
"plotly.js-strict-dist-min": "^2.35.2",
"prismjs": "^1.28.0",
"proj4": "2.21.0",
"proj4": "^2.21.0",
"rc-slider": "^11.1.8",
"react": "^18.3.1",
"react-bootstrap": "^2.10.2",
Expand All @@ -77,6 +78,7 @@
"sass-loader": "^12.3.0",
"shapefile": "0.6.6",
"simple-xml-to-json": "^1.2.3",
"sql.js": "1.13.0",
"style-loader": "^3.3.1",
"styled-components": "^6.3.9",
"swiper": "^11.2.1",
Expand Down Expand Up @@ -224,7 +226,7 @@
"<rootDir>/node_modules"
],
"moduleNameMapper": {
"\\.(jpg|jpeg|png|gif|eot|otf|webp|svg|ttf|woff|woff2|mp4|webm|wav|mp3|m4a|aac|oga)$": "<rootDir>/reactapp/__tests__/utilities/fileMock.js",
"\\.(jpg|jpeg|png|gif|eot|otf|webp|svg|ttf|woff|woff2|mp4|webm|wav|mp3|m4a|aac|oga|wasm)$": "<rootDir>/reactapp/__tests__/utilities/fileMock.js",
"^.+\\.module\\.(css|sass|scss|less)$": "identity-obj-proxy",
"\\.(css|less|scss|sss|styl)$": "<rootDir>/node_modules/jest-css-modules"
},
Expand Down
156 changes: 156 additions & 0 deletions reactapp/__tests__/components/map/ModuleLoader.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,10 @@ import moduleLoader, {
withAutoCrossOrigin,
zarrSourceToGeoTIFF,
applyAutoRamp,
loadGeoPackage,
s3UrlToHttps,
registerGeoPackageProjections,
GeoPackageError,
} from "components/map/ModuleLoader";
import { fromUrl } from "geotiff";
import WebGLTile from "ol/layer/WebGLTile.js";
Expand Down Expand Up @@ -52,9 +56,18 @@ import {
defaultStroke,
defaultStrokeWidth,
} from "components/inputs/RuleEditor.js";
import { get as getProjection } from "ol/proj";
import proj4 from "proj4";
import { loadGpkg } from "ol-load-geopackage";

jest.mock("geotiff", () => ({ fromUrl: jest.fn() }));

jest.mock("ol-load-geopackage", () => ({
__esModule: true,
initSqlJsWasm: jest.fn(),
loadGpkg: jest.fn(),
}));

jest.mock("ol/source/GeoTIFF.js", () => {
const ActualSource = jest.requireActual("ol/source/Source.js").default;
const spy = jest.fn();
Expand Down Expand Up @@ -2721,6 +2734,149 @@ describe("applyAutoRamp", () => {
});
});

describe("s3UrlToHttps", () => {
test("passes an https url through unchanged", () => {
expect(s3UrlToHttps("https://x/y.gpkg")).toBe("https://x/y.gpkg");
});

test("returns non-string input unchanged", () => {
expect(s3UrlToHttps(undefined)).toBeUndefined();
});

test("converts s3:// to a virtual-hosted https url, sniffing the region", () => {
expect(
s3UrlToHttps("s3://cog-s3-test-1234-us-east-1-an/Guatemala_IBF/f.gpkg"),
).toBe(
"https://cog-s3-test-1234-us-east-1-an.s3.us-east-1.amazonaws.com/Guatemala_IBF/f.gpkg",
);
});

test("honors a region embedded in the bucket name", () => {
expect(s3UrlToHttps("s3://data-eu-west-2-x/k.gpkg")).toBe(
"https://data-eu-west-2-x.s3.eu-west-2.amazonaws.com/k.gpkg",
);
});

test("defaults the region when none is in the bucket name", () => {
expect(s3UrlToHttps("s3://my-bucket/a/b.gpkg")).toBe(
"https://my-bucket.s3.us-east-1.amazonaws.com/a/b.gpkg",
);
});

test("handles an s3 url with no key", () => {
expect(s3UrlToHttps("s3://plain-bucket")).toBe(
"https://plain-bucket.s3.us-east-1.amazonaws.com/",
);
});
});

describe("registerGeoPackageProjections", () => {
test("registers WGS84 UTM zones with proj4 and OpenLayers", () => {
registerGeoPackageProjections();
expect(proj4.defs("EPSG:32615")).toBeTruthy();
expect(proj4.defs("EPSG:32715")).toBeTruthy();
expect(getProjection("EPSG:32615")).not.toBeNull();
});

test("is idempotent", () => {
expect(() => {
registerGeoPackageProjections();
registerGeoPackageProjections();
}).not.toThrow();
});
});

describe("loadGeoPackage", () => {
test("returns the requested table's vector source", async () => {
const src = new VectorSource();
loadGpkg.mockResolvedValue([{ roads: src }, {}]);
const out = await loadGeoPackage(
{ props: { url: "https://h/t1.gpkg", layer: "roads" } },
"EPSG:3857",
);
expect(out).toBe(src);
expect(loadGpkg).toHaveBeenCalledWith("https://h/t1.gpkg", "EPSG:3857");
});

test("translates an s3:// url before loading", async () => {
loadGpkg.mockResolvedValue([{ t: new VectorSource() }, {}]);
await loadGeoPackage(
{ props: { url: "s3://b-us-east-1-x/t2.gpkg", layer: "t" } },
"EPSG:3857",
);
expect(loadGpkg).toHaveBeenCalledWith(
"https://b-us-east-1-x.s3.us-east-1.amazonaws.com/t2.gpkg",
"EPSG:3857",
);
});

test("rejects when the url is missing", async () => {
await expect(
loadGeoPackage({ props: { layer: "t" } }, "EPSG:3857"),
).rejects.toThrow(GeoPackageError);
expect(loadGpkg).not.toHaveBeenCalled();
});

test("rejects when the table name is missing", async () => {
await expect(
loadGeoPackage({ props: { url: "https://h/t3.gpkg" } }, "EPSG:3857"),
).rejects.toThrow(GeoPackageError);
});

test("rejects an unknown table, listing the available ones", async () => {
loadGpkg.mockResolvedValue([
{ roads: new VectorSource(), bldgs: new VectorSource() },
{},
]);
await expect(
loadGeoPackage(
{ props: { url: "https://h/t4.gpkg", layer: "nope" } },
"EPSG:3857",
),
).rejects.toThrow(/roads|bldgs/);
});

test("parses a file only once across layers (cache by url+projection)", async () => {
loadGpkg.mockResolvedValue([{ t: new VectorSource() }, {}]);
const cfg = { props: { url: "https://h/t5.gpkg", layer: "t" } };
await loadGeoPackage(cfg, "EPSG:3857");
await loadGeoPackage(cfg, "EPSG:3857");
expect(loadGpkg).toHaveBeenCalledTimes(1);
});

test("does not cache a failed load", async () => {
loadGpkg
.mockRejectedValueOnce(new Error("boom"))
.mockResolvedValue([{ t: new VectorSource() }, {}]);
const cfg = { props: { url: "https://h/t6.gpkg", layer: "t" } };
await expect(loadGeoPackage(cfg, "EPSG:3857")).rejects.toThrow("boom");
const out = await loadGeoPackage(cfg, "EPSG:3857");
expect(out).toBeInstanceOf(VectorSource);
expect(loadGpkg).toHaveBeenCalledTimes(2);
});

test("caches separately per display projection", async () => {
loadGpkg.mockResolvedValue([{ t: new VectorSource() }, {}]);
const cfg = { props: { url: "https://h/t7.gpkg", layer: "t" } };
await loadGeoPackage(cfg, "EPSG:3857");
await loadGeoPackage(cfg, "EPSG:4326");
expect(loadGpkg).toHaveBeenCalledTimes(2);
});

test("moduleLoader routes a GeoPackage config to loadGeoPackage", async () => {
const src = new VectorSource();
loadGpkg.mockResolvedValue([{ roads: src }, {}]);
const out = await moduleLoader(
{
type: "GeoPackage",
props: { url: "https://h/t8.gpkg", layer: "roads" },
},
"EPSG:3857",
);
expect(out).toBe(src);
});
});

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
Expand Down
Loading