A STAC viewer for reviewing a catalog before it's published.
- Reads static STAC catalogs and stac-geoparquet files, from S3 or local folders.
- Draws every collection on a map.
- Lets a reviewer mark each collection approved, needs revision or rejected.
- Approving a collection copies its files into the next environment (Development to Test, Test to Production).
It only ever writes two things:
- QC decisions, to a JSON sidecar beside the collection's file
(
gages.parquetgetsgages.edits.json). The parquet itself is never touched. - Promoted collections, copied byte for byte into the next environment's first source.
You need Node.js 20 or newer. On WSL, install Node inside the Linux distro, not on Windows.
-
Install dependencies:
npm install
-
Make a
.envfrom the example, then point it at your catalogs (see Sources):cp .env.example .env
In PowerShell, use
Copy-Item .env.example .env. -
Start the dev server:
npm run dev
-
Open http://localhost:3000. It opens in Development.
Two things to know:
- Reading from S3 needs AWS credentials with access to the bucket. Without them the pages still load, but empty, with a note that collections couldn't be loaded.
- There's no identity provider yet. The sign-in page opens Development whatever you type, and the account shown is a fixed role.
| Command | What it does |
|---|---|
npm run dev |
Dev server with hot reload |
npm run build, then npm start |
Production build. Use it for demos so pages don't compile on first click |
npm run lint |
ESLint |
npm run typecheck |
TypeScript check |
npm run a11y |
Accessibility check (WCAG 2.2 AA) against the running dev server: axe on each kind of page in light and dark, plus the layout at 320px wide. Run npx playwright install chromium once first |
Settings live in .env. .env.example explains every variable, including
optional ones not covered here.
Each environment reads from its own variable:
| Environment | Variable |
|---|---|
| Sandbox | STAC_SOURCES_SANDBOX |
| Development | STAC_SOURCES_DEV |
| Test | STAC_SOURCES_TEST |
| Production | STAC_SOURCES_PROD |
Each value is one or more of these, separated by commas:
- A STAC catalog,
catalog.jsonor a versioned name likecatalog-v1.json. It's followed through itschildlinks to its collections, and each collection's items come from linked GeoParquet or JSON files. - A single STAC Collection JSON file, with linked items or metadata only.
- An S3 prefix or local folder. Every parquet file and
catalog.jsonin it is read. - A single parquet file, on S3 or local.
Collections can link individual JSON Items with rel="item", or JSON
ItemCollections (GeoJSON FeatureCollections) with rel="items". ItemCollection
next links are followed, repeated files and item ids are read once, and
relative item links and asset hrefs resolve against the JSON file containing
them. Linked GeoParquet takes precedence when both formats are available.
JSON reads and ETag checks run eight files at a time; GeoParquet is still
recommended for large collections. Changes to linked items or pages are
picked up at the configured refresh interval, even if the collection is unchanged.
Every node is read as either a dataset or a folder (a node that only holds
other nodes), worked out from what it holds on every load. A collection with
items, or with a link to where its items are (a GeoParquet file, even with
zero rows yet, or JSON items), is a dataset. Otherwise a document typed
Catalog is a folder; a leaf with collection-level assets is a dataset (a
bundle of files); a node with children stays a folder even when a manifest
or README is attached to it; and anything with nothing in it is a folder.
A node whose parent document was listed but didn't load sits at the top of
the list and its row says so. Missing or unreadable linked files still report
an error; they aren't treated as intentionally empty collections.
The home page lists datasets, grouped under the top-level folder each sits
in. Folders are never rows: a heading opens one in the rail (?in=<slug>),
with its datasets under the folders inside it and its empty folders folded
at the end, and top-level folders with nothing in them yet are named at the
foot of the list. Search matches datasets and folders by title and
description, without bringing in a match's descendants, and lists the two
apart; folders that share a name are one row that opens to where each sits.
The review counts, the map and the Admin queue cover datasets only. A folder's
page shows what's inside it and where it sits, with no map, items or review
state. Loaded children whose parents couldn't be loaded stay at the top level,
and rootless cyclic branches stay visible rather than disappearing.
Dataset pages keep the home page's shape: the description (folded to start) and the items on the left, the map with the assets and metadata on the right. Item pages keep their maps at the top left. Assets open to begin with and fold away. On desktop, the handle on the edge of the map hides the dataset list and brings it back. Long item lists scroll in their own box so the rest of the page stays in view; item search, sort and paging stay outside the scrolling rows. Phones keep the stacked map and list.
There are no defaults in the code, so an environment left unset reads nothing
and its pages load empty. The example file points Sandbox and Production at
./data/sandbox and ./data/prod, and Development and Test at placeholder
S3 paths for you to replace.
AWS credentials come from the SDK's usual chain: AWS_PROFILE, env keys, or
a task or instance role; nothing has to name the role.
- Approving copies a collection's parquet file into the next environment. That works for parquet files that sit directly in a source folder or prefix.
- A collection read through a STAC catalog can't be copied yet. Approving it records the decision, and the review sheet says the copy is coming soon.
- Sandbox is outside promotion. Approving there records the decision and copies nothing, and nothing is ever promoted into it.
The app's bare address opens Development, for a deployment whose Production is still empty. Once collections have been approved through, open on Production instead:
STAC_HOME_ENV=prodIt takes sandbox, dev, test or prod, and is read per request, so a
restart picks it up with no rebuild. Production's home page also answers at
/prod whichever you pick.
The home map can focus on one area, like a state or a river basin:
STAC_MAP_AREA=./areas/south-platte.geojson
STAC_MAP_AREA_NAME=the South Platte basinWith an area set, the map opens on it, outlines it and dims everything outside, and collections that cover most of it say "Whole area" in the list.
STAC_MAP_AREA_NAMEis used in phrases like "Fit to the South Platte basin", so include "the" if it reads better. If it's unset, the file'snameproperty is used.- The polygons are sent to the browser with the page, so simplify them first. 10 to 50 KB is plenty for a state or a large basin.
- Both are read when the server starts. Changing them needs a restart, not a rebuild.
- Without an area, the map opens on everything the catalog covers and draws no outline. Collections with a national or world-sized box are left out of that view.
Each project runs its own copy with its own .env. Everything in
Configuration applies; this is what a team starting cold
also needs to know.
The app reads stac-geoparquet and static STAC JSON items.
- A STAC catalog's
childlinks lead, through any sub-catalogs, to collection files. Each collection links to its parquet file with anyrelbutself, as long as the link'stypesays parquet (application/vnd.apache.parquet) or its href ends in.parquet,.geoparquetor.pq. Relative hrefs are fine. - Collections can instead link JSON Items or paginated ItemCollections as described in Sources. Missing or invalid linked documents report an error rather than appearing as empty collections.
- Sources and the files they link to have to be on S3 (
s3://) or a local path.https://isn't read: the page notes that collections couldn't be loaded, and the server log says why. - A parquet file with no
idcolumn (a data table an asset points at, say) is left out of the catalog rather than shown as a collection. - A file written from a pandas frame of items, with bbox, links, assets and
properties as JSON text and the geometry as WKB bytes with no
geometadata, is read too. A geometry that still isn't valid is dropped from its item, and a collection that can't be read at all is left out with a note on the page, so one bad file doesn't take the catalog with it. - Promotion copies parquet files that sit directly in a source folder or prefix. A collection read through a catalog records its approval but isn't copied yet (see Promotion).
The server's credentials need:
s3:ListBucketon every source prefix, ands3:GetObjecton everything the sources and their catalogs link to, including assets people will download or preview.s3:PutObjectbeside every source file in every environment, for the QC sidecars (*.edits.json), and on Test's and Production's first source folder, where promoted collections land.
QC saves are conditional writes (If-Match and If-None-Match on
PutObject), so an S3-compatible store has to support those.
There's no identity provider yet, so anyone who can reach the app can approve a collection, and approving copies files into Test or Production with the server's credentials. Serve it behind the deployment's own auth (an ALB with OIDC, a VPN, an authenticating proxy) until sign-in is built. QC records don't name who made them, for the same reason.
Every environment's catalog is held in memory, all four at once, and a parquet file that changes is read again in full. Parsing is well under a second for a hundred thousand items. Memory grows with the item count and how many properties each item carries, so a project with hundreds of thousands of items should size the server for it and watch its first load. Nothing larger has been tried.
Every push to main publishes ghcr.io/dewberry/stac-x:latest for
linux/amd64 and linux/arm64, plus a sha-<short sha> tag for that commit
(see .github/workflows/docker.yml). Tagging a release v1.2.3 also
publishes 1.2.3 and 1.2.
The image is a production build with no hot reload. Use it to check what
ships, and keep using npm run dev day to day.
The published image is built to be served under /stac, where Production
lives (https://ffrd.cloud.dewberryanalytics.com/stac).
- Next bakes the path into the build, so it can't be changed when the container starts. A project served anywhere else, including at the domain's root, builds its own image (below).
- Pages,
_nextassets and API routes all answer under/stac/.... The proxy in front should pass the path through without stripping the prefix. - To build for another path, set
NEXT_PUBLIC_BASE_PATHas a build arg. Leave it out and the app answers at/.
If the ghcr.io package is private to you, log in first with a classic
GitHub token that has read:packages:
echo $GITHUB_TOKEN | docker login ghcr.io -u <github-user> --password-stdin
docker run --rm -p 3000:3000 --env-file .env ghcr.io/dewberry/stac-x:latestIt answers at http://localhost:3000/stac.
To build and run your own instead:
docker build -t stac-x .
# or, to match Production
docker build --build-arg NEXT_PUBLIC_BASE_PATH=/stac -t stac-x .
docker run --rm -p 3000:3000 --env-file .env -v "$PWD/data:/app/data" stac-xThe container reads the same variables as .env.example. A few things about
its files:
- Local sources live at
/app/dataand are lost when the container stops. The-vabove mounts the repo'sdatafolder there, so promoted collections and their QC edits are kept. - Edits to collections read from S3 go to S3 sidecars either way.
- The image includes the
areasfolder. Mount your own over/app/areasto use a different map area.
Full-Stack Software Engineering
