From 3bed64c41abe7a5fdfc24c6ea1d680430433dbe5 Mon Sep 17 00:00:00 2001
From: John Huebbe
Date: Sat, 22 Aug 2026 20:48:12 -0500
Subject: [PATCH 1/2] Fix color-relief display artifacts
---
frontend/src/components/MapView.tsx | 12 +++++++++++-
1 file changed, 11 insertions(+), 1 deletion(-)
diff --git a/frontend/src/components/MapView.tsx b/frontend/src/components/MapView.tsx
index ffc53d4..390f9e4 100644
--- a/frontend/src/components/MapView.tsx
+++ b/frontend/src/components/MapView.tsx
@@ -237,7 +237,17 @@ export function MapView({
map.addLayer({
id: layer, type: "color-relief", source: src,
layout: { visibility: remVisible ? "visible" : "none" },
- paint: { "color-relief-color": colorReliefExpr(opts.ramp, opts.min, opts.max, opts.reverse, opts.transparent) as any, "color-relief-opacity": 0.95 },
+ paint: {
+ "color-relief-color": colorReliefExpr(
+ opts.ramp,
+ opts.min,
+ opts.max,
+ opts.reverse,
+ opts.transparent
+ ) as any,
+ "color-relief-opacity": 0.95,
+ "resampling": "nearest",
+ },
} as any);
remRef.current = { src, layer };
// Bring overlay layers above the freshly added REM tint (river stays below REM intentionally)
From 25e4ebb65bc26a139046c967a385c7aa7c08de57 Mon Sep 17 00:00:00 2001
From: John Huebbe
Date: Tue, 25 Aug 2026 11:06:38 -0500
Subject: [PATCH 2/2] Add configurable DEM sources and centerline imports
---
.env.example | 25 +
README.md | 63 ++-
backend/app/centerline.py | 186 +++++++-
backend/app/main.py | 578 +++++++++++++++++++++++-
backend/app/schemas.py | 15 +
backend/app/terrain.py | 70 ++-
backend/tests/__init__.py | 0
backend/tests/test_centerline_import.py | 188 ++++++++
docker-compose.local.yml | 12 +
docker-compose.yml | 11 +
docs/centerline-imports.md | 40 ++
docs/dem-sources.md | 80 ++++
frontend/nginx.conf | 16 +-
frontend/src/App.tsx | 277 ++++++++----
frontend/src/components/SidePanel.tsx | 133 +++++-
frontend/src/lib/api.ts | 96 ++++
16 files changed, 1663 insertions(+), 127 deletions(-)
create mode 100644 backend/tests/__init__.py
create mode 100644 backend/tests/test_centerline_import.py
create mode 100644 docs/centerline-imports.md
create mode 100644 docs/dem-sources.md
diff --git a/.env.example b/.env.example
index 4a9d8c0..5d17fbe 100644
--- a/.env.example
+++ b/.env.example
@@ -12,5 +12,30 @@ VITE_API_BASE=
# Max number of stored REM job dirs before oldest are evicted (bounds disk use).
MAX_COGS=200
+# Maximum GeoJSON, GeoPackage, or zipped-shapefile centerline upload (64 MiB).
+CENTERLINE_UPLOAD_MAX_BYTES=67108864
+# Basic processing limits protect public servers from unexpectedly complex files.
+CENTERLINE_MAX_FEATURES=10000
+CENTERLINE_MAX_VERTICES=500000
+
+# Optional custom DEM sources for the server engine. Both are disabled by
+# default, so public deployments do not gain upload/library access accidentally.
+DEM_UPLOAD_ENABLED=false
+# Maximum streamed upload size in bytes (default 20 GiB) and retention period.
+DEM_UPLOAD_MAX_BYTES=21474836480
+DEM_UPLOAD_TTL_HOURS=24
+# Expired uploads are removed at startup and on this repeating schedule.
+DEM_UPLOAD_CLEANUP_INTERVAL_MINUTES=15
+
+# To expose a read-only server DEM library, set the host directory and the
+# in-container path exactly as shown. Leave DEM_LIBRARY_DIR blank to hide it.
+DEM_LIBRARY_HOST_DIR=./dem-library
+DEM_LIBRARY_DIR=
+DEM_LIBRARY_LABEL=Server library
+
+# Maximum pixels retained from a custom DEM inside the current viewport.
+# Larger native-resolution windows are downsampled before RiverREM to bound RAM.
+CUSTOM_DEM_MAX_PIXELS=45000000
+
# Short git commit hash shown in the footer (optional).
GIT_SHA=dev
diff --git a/README.md b/README.md
index d343825..6de647e 100644
--- a/README.md
+++ b/README.md
@@ -33,7 +33,7 @@ transparency) stays client-side and recolours instantly with no recompute.
| | **Server** (default) | **Client** (beta) |
|---|---|---|
| Where | FastAPI + GDAL + RiverREM | entirely in the browser |
-| DEM | Mapterhorn tiles -> UTM mosaic | Mapterhorn tiles, read per-tile |
+| DEM | Mapterhorn, COG URL, streamed upload, or server library -> UTM | Mapterhorn tiles, read per-tile |
| IDW | RiverREM: **k-NN, power 1**, KD-tree, `k` from sinuosity (faithful) | **all sampled points**, power configurable (QGIS-style) |
| Output | single-band float32 COG (EPSG:3857) | `rem://` terrarium tiles built live, per tile |
| Strength | fidelity, full-res, exports | no backend, live recompute, fast |
@@ -148,16 +148,69 @@ recolours instantly. The client engine skips all of that and runs in the browser
"zoom": 13, "resolution_multiplier": 1, "idw_power": 2,
"centerline_mode": "geojson",
"centerline_geojson": { "type": "FeatureCollection", "features": [] },
- "source_cog_url": null
+ "source_cog_url": null,
+ "source_dem_ref": null
}
```
-> `{ job_id, cog_url, dem_url, bounds:[w,s,e,n], rem_min, rem_max, width, height, river_name, river_length_m }`
-Other endpoints: `POST /cog/ingest` (reproject any-CRS COG to 3857), `POST /upload`
+Other endpoints: `GET /capabilities`, `POST /centerline/import`, `GET /dem/library`, `POST /dem/uploads`,
+`PUT /dem/uploads/{id}`, `POST /cog/ingest` (reproject any-CRS COG to 3857), `POST /upload`
(zipped shapefile -> `upload_id`), `POST /thumb` (store a run thumbnail), `POST /runs/prune`.
`source_cog_url` lets RiverREM use your own elevation COG (read via GDAL `/vsicurl/`)
instead of Mapterhorn.
+Centerline uploads accept WGS84 GeoJSON, GeoPackage, and zipped shapefiles. GIS
+files with an assigned CRS are reprojected to WGS84 for the map; RiverREM then
+reprojects the normalized linework to the DEM CRS for computation. See
+[`docs/centerline-imports.md`](docs/centerline-imports.md).
+
+---
+
+## Using custom DEMs and centerlines
+
+Custom DEMs are available with the **Server** engine. Open **Compute**, choose a
+**DEM source**, and select one of:
+
+- **Mapterhorn** — downloads terrain for the current viewport; no setup required.
+- **COG URL** — enter an HTTP(S) URL for a remotely accessible elevation COG.
+- **Upload local DEM** — stream a georeferenced `.tif`/`.tiff` from the browser.
+ The server must enable uploads; uploaded DEMs expire and are removed automatically.
+- **Server library** — choose a DEM installed by the server owner. This option is
+ shown only when a library has been configured.
+
+To enable the optional sources, copy `.env.example` to `.env`, configure the
+desired settings, and rebuild the stack. A typical local configuration is:
+
+```dotenv
+DEM_UPLOAD_ENABLED=true
+DEM_UPLOAD_MAX_BYTES=21474836480
+DEM_UPLOAD_TTL_HOURS=24
+DEM_LIBRARY_HOST_DIR=/absolute/host/path/to/dems
+DEM_LIBRARY_DIR=/dem-library
+DEM_LIBRARY_LABEL=Available DEMs
+CUSTOM_DEM_MAX_PIXELS=45000000
+```
+
+The library is mounted read-only. On a public deployment it contains only DEMs
+installed by that server's administrator; leaving `DEM_LIBRARY_DIR` blank hides
+the library option. Large custom DEM viewports are reduced only when they exceed
+`CUSTOM_DEM_MAX_PIXELS`. The server checks that a custom DEM overlaps the current
+viewport and uploaded centerline before starting RiverREM. Full configuration,
+retention, and public-server guidance is in [`docs/dem-sources.md`](docs/dem-sources.md).
+
+To import a centerline, select **Draw**, click **Upload Centerline File**, and use:
+
+- `.geojson`/`.json` with EPSG:4326 longitude/latitude coordinates;
+- `.gpkg` with an assigned CRS (choose a layer if it contains several); or
+- `.zip` containing the `.shp`, `.shx`, and `.dbf` components of a Shapefile.
+
+GeoPackage and Shapefile linework is converted to EPSG:4326 for display. If its
+CRS metadata is missing, the UI asks for a source CRS such as `EPSG:6344`.
+The import summary shows the detected CRS, line/coordinate counts, length, and
+any skipped empty, invalid, or non-line features. See
+[`docs/centerline-imports.md`](docs/centerline-imports.md) for limits and validation details.
+
---
## Run it
@@ -182,7 +235,9 @@ docker compose -f docker-compose.local.yml up --build
Open http://localhost:8088 (override with `WEB_PORT=NNNN`).
Backend env: `TERRAIN_TILE_URL`, `TERRAIN_ENCODING` (`terrarium`|`mapbox`),
-`TERRAIN_MAX_ZOOM`, `PUBLIC_BASE`, `DATA_DIR`, `MAX_COGS`.
+`TERRAIN_MAX_ZOOM`, `PUBLIC_BASE`, `DATA_DIR`, `MAX_COGS`. Optional custom DEM
+uploads and a read-only server library are configured in
+[`docs/dem-sources.md`](docs/dem-sources.md).
---
diff --git a/backend/app/centerline.py b/backend/app/centerline.py
index a400054..a92ed24 100644
--- a/backend/app/centerline.py
+++ b/backend/app/centerline.py
@@ -13,12 +13,175 @@
from __future__ import annotations
import os
+import json
+import math
import geopandas as gpd
import osmnx
+from osgeo import ogr
from shapely.geometry import box, shape
WATERWAY_TAGS = {"waterway": ["river", "stream", "tidal channel"]}
+CENTERLINE_MAX_FEATURES = max(1, int(os.environ.get("CENTERLINE_MAX_FEATURES", "10000")))
+CENTERLINE_MAX_VERTICES = max(2, int(os.environ.get("CENTERLINE_MAX_VERTICES", "500000")))
+
+
+class CenterlineCrsRequired(ValueError):
+ """Raised when a GIS dataset has linework but no assigned CRS."""
+
+
+def _dataset_layer_names(path: str) -> list[str]:
+ dataset = ogr.Open(path, 0)
+ if dataset is None:
+ raise ValueError("The centerline file could not be opened as a GIS dataset.")
+ names = [dataset.GetLayerByIndex(i).GetName() for i in range(dataset.GetLayerCount())]
+ dataset = None
+ return names
+
+
+def _crs_label(gdf: gpd.GeoDataFrame) -> str:
+ authority = gdf.crs.to_authority() if gdf.crs is not None else None
+ return f"{authority[0]}:{authority[1]}" if authority else str(gdf.crs)
+
+
+def _vertex_count(geometry) -> int:
+ if geometry.geom_type == "LineString":
+ return len(geometry.coords)
+ if geometry.geom_type == "MultiLineString":
+ return sum(len(line.coords) for line in geometry.geoms)
+ return 0
+
+
+def import_centerline_dataset(
+ path: str,
+ *,
+ display_name: str,
+ input_crs: str | None = None,
+ force_geojson_wgs84: bool = False,
+ layer_id_prefix: str = "",
+) -> list[dict]:
+ """Read all line layers and return WGS84 GeoJSON plus layer metadata.
+
+ RFC 7946 GeoJSON is always treated as EPSG:4326. Other formats use their
+ embedded CRS, or `input_crs` when the dataset does not declare one.
+ """
+ layer_names = _dataset_layer_names(path)
+ if not layer_names:
+ raise ValueError(f"{display_name} does not contain any GIS layers.")
+
+ results: list[dict] = []
+ missing_crs_layers: list[str] = []
+ unsupported_layers: list[str] = []
+ for layer_name in layer_names:
+ gdf = gpd.read_file(path, layer=layer_name)
+ if gdf.empty or "geometry" not in gdf:
+ continue
+ warnings: list[str] = []
+ usable_mask = gdf.geometry.notna() & ~gdf.geometry.is_empty
+ empty_count = int((~usable_mask).sum())
+ if empty_count:
+ warnings.append(f"Skipped {empty_count} empty geometr{'y' if empty_count == 1 else 'ies'}.")
+ gdf = gdf[usable_mask].copy()
+ line_mask = gdf.geometry.geom_type.isin(["LineString", "MultiLineString"])
+ unsupported_count = int((~line_mask).sum())
+ if unsupported_count:
+ warnings.append(
+ f"Skipped {unsupported_count} non-line feature{'s' if unsupported_count != 1 else ''}."
+ )
+ gdf = gdf[line_mask].copy()
+ if len(gdf) > CENTERLINE_MAX_FEATURES:
+ raise ValueError(
+ f"Layer '{layer_name}' contains {len(gdf):,} line features; "
+ f"the server limit is {CENTERLINE_MAX_FEATURES:,}."
+ )
+ # Inspect individual Shapely objects here rather than GeoSeries.length:
+ # source data can be geographic, where GeoPandas warns about interpreting
+ # length as a real-world distance (we only need a zero-length check).
+ nonzero_mask = gdf.geometry.map(lambda geometry: geometry.length > 0)
+ valid_mask = gdf.geometry.is_valid & nonzero_mask
+ invalid_count = int((~valid_mask).sum())
+ if invalid_count:
+ warnings.append(
+ f"Skipped {invalid_count} invalid or zero-length line{'s' if invalid_count != 1 else ''}."
+ )
+ gdf = gdf[valid_mask].copy()
+ if gdf.empty:
+ unsupported_layers.append(layer_name)
+ continue
+
+ vertex_count = sum(_vertex_count(geometry) for geometry in gdf.geometry)
+ if vertex_count > CENTERLINE_MAX_VERTICES:
+ raise ValueError(
+ f"Layer '{layer_name}' contains {vertex_count:,} coordinates; "
+ f"the server limit is {CENTERLINE_MAX_VERTICES:,}. Simplify the centerline and try again."
+ )
+
+ if force_geojson_wgs84:
+ # GeoJSON coordinates are defined as WGS84 longitude/latitude. Do
+ # not honor legacy/non-standard projected `crs` members silently.
+ gdf = gdf.set_crs("EPSG:4326", allow_override=True)
+ bounds = gdf.total_bounds
+ if (
+ not all(math.isfinite(float(value)) for value in bounds)
+ or bounds[0] < -180 or bounds[2] > 180
+ or bounds[1] < -90 or bounds[3] > 90
+ ):
+ raise ValueError(
+ "GeoJSON coordinates must use WGS 84 (EPSG:4326) in "
+ "[longitude, latitude] order."
+ )
+ elif gdf.crs is None:
+ if not input_crs:
+ missing_crs_layers.append(layer_name)
+ continue
+ try:
+ gdf = gdf.set_crs(input_crs, allow_override=True)
+ except Exception as exc:
+ raise ValueError(f"Invalid input CRS '{input_crs}': {exc}") from exc
+
+ source_crs = _crs_label(gdf)
+ try:
+ wgs84 = gdf.to_crs("EPSG:4326")
+ except Exception as exc:
+ raise ValueError(f"Could not convert layer '{layer_name}' to EPSG:4326: {exc}") from exc
+ bounds = wgs84.total_bounds
+ if (
+ not all(math.isfinite(float(value)) for value in bounds)
+ or bounds[0] < -180 or bounds[2] > 180
+ or bounds[1] < -90 or bounds[3] > 90
+ ):
+ raise ValueError(f"Layer '{layer_name}' produced coordinates outside the WGS84 range.")
+
+ try:
+ metric_crs = wgs84.estimate_utm_crs()
+ length_m = float(wgs84.to_crs(metric_crs).geometry.length.sum()) if metric_crs else 0.0
+ except Exception:
+ length_m = 0.0
+
+ layer_id = f"{layer_id_prefix}{layer_name}"
+ results.append({
+ "id": layer_id,
+ "name": layer_name,
+ "crs": source_crs,
+ "featureCount": int(len(wgs84)),
+ "vertexCount": vertex_count,
+ "lengthM": round(length_m, 1),
+ "warnings": warnings,
+ "geojson": json.loads(wgs84.to_json(drop_id=True)),
+ })
+
+ if missing_crs_layers:
+ raise CenterlineCrsRequired(
+ "CRS metadata is missing for line layer(s): " + ", ".join(missing_crs_layers)
+ )
+ if not results:
+ if unsupported_layers:
+ raise ValueError(
+ f"{display_name} has no usable centerlines. Centerlines must be non-empty "
+ "LineString or MultiLineString features."
+ )
+ raise ValueError(f"{display_name} does not contain a LineString or MultiLineString layer.")
+ return results
def _features_from_bbox(west, south, east, north):
@@ -94,17 +257,25 @@ def geojson_to_shapefile(geojson: dict, out_path: str) -> str:
# flatten to LineStrings, then merge touching ones
lines = []
for g in geoms:
- if isinstance(g, LineString):
+ if isinstance(g, LineString) and not g.is_empty:
lines.append(g)
elif isinstance(g, MultiLineString):
- lines.extend(g.geoms)
- merged = linemerge(lines) if lines else None
+ lines.extend(line for line in g.geoms if not line.is_empty)
+ if not lines:
+ raise ValueError("The uploaded GeoJSON contains no usable LineString centerline.")
+ for line in lines:
+ for x, y, *_ in line.coords:
+ if abs(x) > 180 or abs(y) > 90:
+ raise ValueError(
+ "Centerline coordinates must use WGS 84 (EPSG:4326) in longitude, latitude order."
+ )
+ merged = linemerge(lines)
if isinstance(merged, LineString):
out_geoms = [merged]
elif isinstance(merged, MultiLineString):
out_geoms = list(merged.geoms)
else:
- out_geoms = geoms
+ raise ValueError("The uploaded GeoJSON centerline could not be converted to line geometry.")
gdf = gpd.GeoDataFrame({"name": ["centerline"] * len(out_geoms)}, geometry=out_geoms, crs="EPSG:4326")
gdf.to_file(out_path)
@@ -113,7 +284,8 @@ def geojson_to_shapefile(geojson: dict, out_path: str) -> str:
def normalize_uploaded_shapefile(upload_dir: str) -> str:
"""Find the .shp inside an uploaded/unzipped directory."""
- for f in os.listdir(upload_dir):
- if f.lower().endswith(".shp"):
- return os.path.join(upload_dir, f)
+ for root, _dirs, files in os.walk(upload_dir):
+ for filename in files:
+ if filename.lower().endswith(".shp"):
+ return os.path.join(root, filename)
raise FileNotFoundError("No .shp found in the uploaded shapefile bundle.")
diff --git a/backend/app/main.py b/backend/app/main.py
index ebbed33..2e3436f 100644
--- a/backend/app/main.py
+++ b/backend/app/main.py
@@ -10,8 +10,11 @@
"""
from __future__ import annotations
+import asyncio
import base64
+from contextlib import asynccontextmanager
import glob
+import hashlib
import json
import logging
import math
@@ -19,18 +22,22 @@
import re
import shutil
import subprocess
+import tempfile
import time
import threading
import uuid
import zipfile
+from pathlib import PurePosixPath
-from fastapi import FastAPI, File, HTTPException, UploadFile
+from fastapi import FastAPI, File, Form, HTTPException, Request, UploadFile
from fastapi.middleware.cors import CORSMiddleware
from fastapi.staticfiles import StaticFiles
-from osgeo import gdal, osr
+from osgeo import gdal, ogr, osr
from .centerline import (
+ CenterlineCrsRequired,
geojson_to_shapefile,
+ import_centerline_dataset,
normalize_uploaded_shapefile,
osm_centerline_geojson,
)
@@ -41,6 +48,7 @@
CogIngestResponse,
ComputeRequest,
ComputeResponse,
+ DemUploadInitRequest,
PruneRequest,
PruneResponse,
ThumbRequest,
@@ -57,7 +65,27 @@
# Cap the number of stored COG job dirs; oldest are evicted past this. The client
# prunes runs whose COG vanished (see /runs/prune), so the UI stays consistent.
MAX_COGS = int(os.environ.get("MAX_COGS", "200"))
-for d in (COG_DIR, UPLOAD_DIR):
+CENTERLINE_UPLOAD_MAX_BYTES = max(
+ 1, int(os.environ.get("CENTERLINE_UPLOAD_MAX_BYTES", str(64 * 1024**2)))
+)
+
+
+def _env_bool(name: str, default: bool = False) -> bool:
+ value = os.environ.get(name)
+ return default if value is None else value.strip().lower() in {"1", "true", "yes", "on"}
+
+
+DEM_UPLOAD_ENABLED = _env_bool("DEM_UPLOAD_ENABLED", False)
+DEM_UPLOAD_MAX_BYTES = max(1, int(os.environ.get("DEM_UPLOAD_MAX_BYTES", str(20 * 1024**3))))
+DEM_UPLOAD_TTL_HOURS = max(1, int(os.environ.get("DEM_UPLOAD_TTL_HOURS", "24")))
+DEM_UPLOAD_CLEANUP_INTERVAL_MINUTES = max(
+ 1, int(os.environ.get("DEM_UPLOAD_CLEANUP_INTERVAL_MINUTES", "15"))
+)
+DEM_UPLOAD_DIR = os.path.realpath(os.environ.get("DEM_UPLOAD_DIR", os.path.join(DATA_DIR, "dem_uploads")))
+DEM_LIBRARY_DIR_RAW = os.environ.get("DEM_LIBRARY_DIR", "").strip()
+DEM_LIBRARY_DIR = os.path.realpath(DEM_LIBRARY_DIR_RAW) if DEM_LIBRARY_DIR_RAW else None
+DEM_LIBRARY_LABEL = os.environ.get("DEM_LIBRARY_LABEL", "Server library").strip() or "Server library"
+for d in (COG_DIR, UPLOAD_DIR, DEM_UPLOAD_DIR):
os.makedirs(d, exist_ok=True)
@@ -87,7 +115,35 @@ def _evict_old_cogs():
except Exception:
pass
-app = FastAPI(title="RiverREM Pipeline")
+async def _dem_upload_cleanup_loop():
+ """Remove expired uploads periodically without requiring an API request."""
+ while True:
+ await asyncio.sleep(DEM_UPLOAD_CLEANUP_INTERVAL_MINUTES * 60)
+ try:
+ await asyncio.to_thread(_prune_dem_uploads)
+ except Exception:
+ logging.getLogger("riverrem.dem").exception("Automatic DEM upload cleanup failed")
+
+
+@asynccontextmanager
+async def _lifespan(_app: FastAPI):
+ cleanup_task = None
+ if DEM_UPLOAD_ENABLED:
+ # Clean on every server start, then continue on a fixed interval.
+ await asyncio.to_thread(_prune_dem_uploads)
+ cleanup_task = asyncio.create_task(_dem_upload_cleanup_loop())
+ try:
+ yield
+ finally:
+ if cleanup_task:
+ cleanup_task.cancel()
+ try:
+ await cleanup_task
+ except asyncio.CancelledError:
+ pass
+
+
+app = FastAPI(title="RiverREM Pipeline", lifespan=_lifespan)
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
@@ -157,26 +213,128 @@ def _set(job_id: str, **kw):
JOBS[job_id].update(kw)
+def _bounds_intersection(a: list[float], b: list[float]) -> list[float] | None:
+ intersection = [max(a[0], b[0]), max(a[1], b[1]), min(a[2], b[2]), min(a[3], b[3])]
+ return intersection if intersection[0] < intersection[2] and intersection[1] < intersection[3] else None
+
+
+def _vector_wgs84_bounds(path: str) -> list[float]:
+ dataset = ogr.Open(path, 0)
+ if dataset is None:
+ raise ValueError("The selected centerline could not be opened for the coverage check")
+ output_bounds: list[float] | None = None
+ for index in range(dataset.GetLayerCount()):
+ layer = dataset.GetLayerByIndex(index)
+ extent = layer.GetExtent()
+ source_srs = layer.GetSpatialRef()
+ if extent is None or source_srs is None:
+ continue
+ source_srs = source_srs.Clone()
+ source_srs.SetAxisMappingStrategy(osr.OAMS_TRADITIONAL_GIS_ORDER)
+ target_srs = osr.SpatialReference()
+ target_srs.ImportFromEPSG(4326)
+ target_srs.SetAxisMappingStrategy(osr.OAMS_TRADITIONAL_GIS_ORDER)
+ transform = osr.CoordinateTransformation(source_srs, target_srs)
+ min_x, max_x, min_y, max_y = extent
+ points = [
+ transform.TransformPoint(x, y)
+ for x, y in ((min_x, min_y), (min_x, max_y), (max_x, min_y), (max_x, max_y))
+ ]
+ bounds = [
+ min(point[0] for point in points), min(point[1] for point in points),
+ max(point[0] for point in points), max(point[1] for point in points),
+ ]
+ if output_bounds is None:
+ output_bounds = bounds
+ else:
+ output_bounds = [
+ min(output_bounds[0], bounds[0]), min(output_bounds[1], bounds[1]),
+ max(output_bounds[2], bounds[2]), max(output_bounds[3], bounds[3]),
+ ]
+ dataset = None
+ if output_bounds is None or not all(math.isfinite(value) for value in output_bounds):
+ raise ValueError("The selected centerline does not have valid geographic bounds")
+ return output_bounds
+
+
+def _preflight_custom_dem(
+ req: ComputeRequest,
+ source_dem_path: str | None,
+ centerline_path: str | None,
+) -> None:
+ """Reject fixed-extent DEM/analysis combinations that cannot produce a REM."""
+ if not req.source_cog_url and not source_dem_path:
+ return
+ if req.source_cog_url and not req.source_cog_url.startswith(("http://", "https://")):
+ raise ValueError("Custom DEM URLs must use http:// or https://")
+ source = source_dem_path or f"/vsicurl/{req.source_cog_url}"
+ dataset = gdal.OpenEx(source, gdal.OF_RASTER | gdal.OF_READONLY)
+ if dataset is None:
+ raise ValueError("The selected custom DEM could not be opened")
+ if not dataset.GetProjection() or dataset.GetGeoTransform(can_return_null=True) is None:
+ dataset = None
+ raise ValueError("The selected custom DEM is missing its CRS or georeferencing")
+ dem_bounds = _to_wgs84_bounds(dataset)
+ dataset = None
+ viewport_bounds = [req.bbox.west, req.bbox.south, req.bbox.east, req.bbox.north]
+ analysis_bounds = _bounds_intersection(dem_bounds, viewport_bounds)
+ if analysis_bounds is None:
+ raise ValueError(
+ "The selected custom DEM does not overlap the current map viewport. "
+ "Move the map to the DEM or choose a different DEM."
+ )
+ if centerline_path:
+ centerline_bounds = _vector_wgs84_bounds(centerline_path)
+ if _bounds_intersection(centerline_bounds, analysis_bounds) is None:
+ raise ValueError(
+ "The centerline does not overlap the part of the selected custom DEM "
+ "inside the current map viewport. Check the CRS, viewport, and DEM selection."
+ )
+
+
def _run_compute(job_id: str, req: ComputeRequest):
_THREAD_JOB[threading.get_ident()] = job_id
job_dir = os.path.join(COG_DIR, job_id)
os.makedirs(job_dir, exist_ok=True)
try:
- _set(job_id, phase="Fetching terrain tiles", pct=0)
- dem_path = os.path.join(job_dir, "dem.tif")
- dem_info = build_dem(req.bbox, req.zoom, req.resolution_multiplier, dem_path, req.source_cog_url)
+ if req.source_cog_url and req.source_dem_ref:
+ raise ValueError("Choose either a source COG URL or a server DEM reference, not both")
+ source_dem_path = _resolve_dem_ref(req.source_dem_ref) if req.source_dem_ref else None
- _set(job_id, phase="Resolving centerline")
+ _set(job_id, phase="Resolving centerline", pct=5)
centerline_shp = None
if req.centerline_mode == "geojson":
if not req.centerline_geojson:
raise ValueError("centerline_geojson required for geojson mode")
- centerline_shp = geojson_to_shapefile(req.centerline_geojson, os.path.join(job_dir, "centerline.shp"))
+ centerline_shp = geojson_to_shapefile(
+ req.centerline_geojson, os.path.join(job_dir, "centerline.shp")
+ )
elif req.centerline_mode == "shapefile":
if not req.upload_id:
raise ValueError("upload_id required for shapefile mode")
centerline_shp = normalize_uploaded_shapefile(os.path.join(UPLOAD_DIR, req.upload_id))
+ if req.source_cog_url or source_dem_path:
+ _set(job_id, phase="Checking custom DEM coverage", pct=8)
+ _preflight_custom_dem(req, source_dem_path, centerline_shp)
+
+ if req.source_dem_ref:
+ source_phase = "Preparing server DEM"
+ elif req.source_cog_url:
+ source_phase = "Reading DEM COG"
+ else:
+ source_phase = "Fetching terrain tiles"
+ _set(job_id, phase=source_phase, pct=10)
+ dem_path = os.path.join(job_dir, "dem.tif")
+ dem_info = build_dem(
+ req.bbox,
+ req.zoom,
+ req.resolution_multiplier,
+ dem_path,
+ req.source_cog_url,
+ source_dem_path,
+ )
+
_set(job_id, phase="Running RiverREM")
rem_cog = os.path.join(job_dir, "rem_REM.tif")
meta = make_rem_cog(
@@ -222,6 +380,11 @@ def _run_compute(job_id: str, req: ComputeRequest):
source_max_zoom=dem_info.get("source_max_zoom"),
dem_zoom=dem_info.get("dem_zoom"),
requested_zoom=dem_info.get("requested_zoom"),
+ dem_downsampled=dem_info.get("dem_downsampled", False),
+ native_width=dem_info.get("native_width"),
+ native_height=dem_info.get("native_height"),
+ processed_dem_width=dem_info.get("dem_width"),
+ processed_dem_height=dem_info.get("dem_height"),
)
_set(job_id, status="done", phase="Done", pct=100, result=resp.model_dump())
# Sidecar metadata for the server-side /gallery (filesystem + JSON, no DB).
@@ -323,18 +486,176 @@ def gallery():
return {"runs": items}
+async def _save_centerline_upload(file: UploadFile, destination: str) -> None:
+ received = 0
+ with open(destination, "wb") as file_obj:
+ while chunk := await file.read(1024 * 1024):
+ received += len(chunk)
+ if received > CENTERLINE_UPLOAD_MAX_BYTES:
+ raise HTTPException(status_code=413, detail="Centerline file exceeds the 64 MB limit")
+ file_obj.write(chunk)
+ if received == 0:
+ raise HTTPException(status_code=400, detail="The selected centerline file is empty")
+
+
+def _safe_extract_shapefile_zip(zip_path: str, destination: str) -> list[str]:
+ """Extract shapefile components without permitting traversal or zip bombs."""
+ allowed = {".shp", ".shx", ".dbf", ".prj", ".cpg", ".qix", ".sbn", ".sbx"}
+ extracted_shps: list[str] = []
+ total_uncompressed = 0
+ with zipfile.ZipFile(zip_path) as archive:
+ members = archive.infolist()
+ if len(members) > 256:
+ raise ValueError("The shapefile archive contains too many files")
+ for member in members:
+ total_uncompressed += member.file_size
+ if total_uncompressed > 256 * 1024**2:
+ raise ValueError("The uncompressed shapefile archive exceeds 256 MB")
+ if member.is_dir():
+ continue
+ posix_path = PurePosixPath(member.filename)
+ if posix_path.is_absolute() or ".." in posix_path.parts:
+ raise ValueError("The shapefile archive contains an unsafe path")
+ if ((member.external_attr >> 16) & 0o170000) == 0o120000:
+ raise ValueError("Symbolic links are not allowed in shapefile archives")
+ # Finder's "Compress" command adds AppleDouble resource-fork files.
+ # Their names can still end in .shp/.dbf/etc, but they are metadata,
+ # not GIS datasets, and GDAL correctly refuses to open them.
+ if "__MACOSX" in posix_path.parts or posix_path.name.startswith("._"):
+ continue
+ if posix_path.name == ".DS_Store":
+ continue
+ suffix = posix_path.suffix.lower()
+ if suffix not in allowed:
+ continue
+ target = os.path.realpath(os.path.join(destination, *posix_path.parts))
+ if os.path.commonpath((os.path.realpath(destination), target)) != os.path.realpath(destination):
+ raise ValueError("The shapefile archive contains an unsafe path")
+ os.makedirs(os.path.dirname(target), exist_ok=True)
+ with archive.open(member) as source, open(target, "wb") as output:
+ shutil.copyfileobj(source, output)
+ if suffix == ".shp":
+ extracted_shps.append(target)
+ if not extracted_shps:
+ raise ValueError("The ZIP archive does not contain a .shp file")
+ for shp_path in extracted_shps:
+ directory = os.path.dirname(shp_path)
+ stem = os.path.splitext(os.path.basename(shp_path))[0].lower()
+ components = {
+ os.path.splitext(filename)[1].lower()
+ for filename in os.listdir(directory)
+ if os.path.splitext(filename)[0].lower() == stem
+ }
+ missing = sorted({".dbf", ".shx"} - components)
+ if missing:
+ raise ValueError(
+ f"Shapefile '{os.path.basename(shp_path)}' is incomplete; missing "
+ + " and ".join(missing)
+ + "."
+ )
+ return sorted(extracted_shps)
+
+
+def _normalize_geojson_document(source_path: str, destination: str) -> None:
+ try:
+ with open(source_path, encoding="utf-8") as file_obj:
+ document = json.load(file_obj)
+ except (UnicodeDecodeError, json.JSONDecodeError) as exc:
+ raise ValueError(f"The selected file is not valid GeoJSON: {exc}") from exc
+ doc_type = document.get("type") if isinstance(document, dict) else None
+ if doc_type == "FeatureCollection":
+ features = document.get("features", [])
+ elif doc_type == "Feature":
+ features = [document]
+ elif doc_type in {"LineString", "MultiLineString"}:
+ features = [{"type": "Feature", "properties": {}, "geometry": document}]
+ else:
+ raise ValueError("GeoJSON must contain a LineString or MultiLineString feature")
+ with open(destination, "w", encoding="utf-8") as file_obj:
+ json.dump({"type": "FeatureCollection", "features": features}, file_obj)
+
+
+@app.post("/centerline/import")
+async def import_centerline_file(
+ file: UploadFile = File(...),
+ input_crs: str | None = Form(None),
+):
+ """Normalize GeoJSON, GeoPackage, or zipped shapefile linework to WGS84."""
+ filename = os.path.basename(file.filename or "centerline")
+ suffix = os.path.splitext(filename)[1].lower()
+ if suffix not in {".geojson", ".json", ".gpkg", ".zip"}:
+ raise HTTPException(
+ status_code=400,
+ detail="Accepted centerline files: .geojson, .json, .gpkg, or zipped shapefile",
+ )
+
+ try:
+ with tempfile.TemporaryDirectory(prefix="riverrem-centerline-") as temp_dir:
+ source_path = os.path.join(temp_dir, f"source{suffix}")
+ await _save_centerline_upload(file, source_path)
+ layers: list[dict] = []
+ if suffix in {".geojson", ".json"}:
+ normalized_path = os.path.join(temp_dir, "normalized.geojson")
+ _normalize_geojson_document(source_path, normalized_path)
+ layers = import_centerline_dataset(
+ normalized_path,
+ display_name=filename,
+ force_geojson_wgs84=True,
+ )
+ if len(layers) == 1:
+ layers[0]["name"] = os.path.splitext(filename)[0]
+ elif suffix == ".gpkg":
+ layers = import_centerline_dataset(
+ source_path,
+ display_name=filename,
+ input_crs=input_crs,
+ )
+ else:
+ extract_dir = os.path.join(temp_dir, "shapefile")
+ os.makedirs(extract_dir)
+ shp_paths = _safe_extract_shapefile_zip(source_path, extract_dir)
+ for shp_path in shp_paths:
+ relative = os.path.relpath(shp_path, extract_dir)
+ imported = import_centerline_dataset(
+ shp_path,
+ display_name=relative,
+ input_crs=input_crs,
+ layer_id_prefix=f"{relative}::",
+ )
+ for layer in imported:
+ layer["name"] = os.path.splitext(relative)[0]
+ layers.extend(imported)
+ return {"filename": filename, "layers": layers}
+ except CenterlineCrsRequired as exc:
+ raise HTTPException(
+ status_code=422,
+ detail={"code": "crs_required", "message": str(exc)},
+ ) from exc
+ except HTTPException:
+ raise
+ except (ValueError, zipfile.BadZipFile) as exc:
+ raise HTTPException(status_code=400, detail=str(exc)) from exc
+ except Exception as exc:
+ logging.getLogger("riverrem.centerline").exception("Centerline import failed")
+ raise HTTPException(status_code=400, detail=f"Could not read centerline file: {exc}") from exc
+
+
@app.post("/upload")
async def upload_shapefile(file: UploadFile = File(...)):
+ """Legacy shapefile upload used by the older compute contract."""
upload_id = uuid.uuid4().hex
dest = os.path.join(UPLOAD_DIR, upload_id)
os.makedirs(dest, exist_ok=True)
- raw = os.path.join(dest, file.filename)
- with open(raw, "wb") as f:
- shutil.copyfileobj(file.file, f)
- if raw.lower().endswith(".zip"):
- with zipfile.ZipFile(raw) as z:
- z.extractall(dest)
- return {"upload_id": upload_id}
+ filename = os.path.basename(file.filename or "centerline.zip")
+ raw = os.path.join(dest, filename)
+ try:
+ await _save_centerline_upload(file, raw)
+ if raw.lower().endswith(".zip"):
+ _safe_extract_shapefile_zip(raw, dest)
+ return {"upload_id": upload_id}
+ except Exception:
+ shutil.rmtree(dest, ignore_errors=True)
+ raise
@app.post("/compute")
@@ -399,10 +720,8 @@ def _to_wgs84_bounds(ds) -> list[float]:
dst.SetAxisMappingStrategy(osr.OAMS_TRADITIONAL_GIS_ORDER)
ct = osr.CoordinateTransformation(src, dst)
corners = [
- (gt[0], gt[3]),
- (gt[0] + gt[1] * W, gt[3]),
- (gt[0], gt[3] + gt[5] * H),
- (gt[0] + gt[1] * W, gt[3] + gt[5] * H),
+ (gt[0] + px * gt[1] + py * gt[2], gt[3] + px * gt[4] + py * gt[5])
+ for px, py in ((0, 0), (W, 0), (0, H), (W, H))
]
lons, lats = [], []
for x, y in corners:
@@ -411,6 +730,225 @@ def _to_wgs84_bounds(ds) -> list[float]:
return [min(lons), min(lats), max(lons), max(lats)]
+def _inspect_dem(path: str, *, dem_ref: str | None = None, display_name: str | None = None):
+ ds = gdal.OpenEx(path, gdal.OF_RASTER | gdal.OF_READONLY)
+ if ds is None or ds.RasterCount != 1:
+ raise ValueError("The DEM must be a readable, single-band raster")
+ if not ds.GetProjection():
+ raise ValueError("The DEM does not contain a coordinate reference system")
+ if ds.GetGeoTransform(can_return_null=True) is None:
+ raise ValueError("The DEM does not contain a geotransform")
+ spatial_ref = ds.GetSpatialRef()
+ crs = None
+ if spatial_ref is not None:
+ authority_name = spatial_ref.GetAuthorityName(None)
+ authority_code = spatial_ref.GetAuthorityCode(None)
+ crs = (
+ f"{authority_name}:{authority_code}"
+ if authority_name and authority_code
+ else spatial_ref.GetName()
+ )
+ result = {
+ "name": display_name or os.path.basename(path),
+ "sizeBytes": os.path.getsize(path),
+ "width": ds.RasterXSize,
+ "height": ds.RasterYSize,
+ "bounds": _to_wgs84_bounds(ds),
+ "crs": crs,
+ }
+ if dem_ref:
+ result["ref"] = dem_ref
+ ds = None
+ return result
+
+
+def _safe_library_files():
+ """Return opaque refs for GeoTIFFs beneath the configured library root."""
+ if not DEM_LIBRARY_DIR or not os.path.isdir(DEM_LIBRARY_DIR):
+ return []
+ root = os.path.realpath(DEM_LIBRARY_DIR)
+ entries = []
+ for current_dir, _, filenames in os.walk(root, followlinks=False):
+ for filename in sorted(filenames):
+ if not filename.lower().endswith((".tif", ".tiff")):
+ continue
+ path = os.path.realpath(os.path.join(current_dir, filename))
+ try:
+ if os.path.commonpath((root, path)) != root or not os.path.isfile(path):
+ continue
+ except ValueError:
+ continue
+ relative = os.path.relpath(path, root)
+ key = hashlib.sha256(relative.encode("utf-8")).hexdigest()[:24]
+ entries.append((f"library:{key}", relative, path))
+ return entries
+
+
+def _prune_dem_uploads() -> int:
+ cutoff = time.time() - DEM_UPLOAD_TTL_HOURS * 3600
+ removed = 0
+ try:
+ entries = os.scandir(DEM_UPLOAD_DIR)
+ except FileNotFoundError:
+ return removed
+ with entries:
+ for entry in entries:
+ if not entry.is_dir(follow_symlinks=False):
+ continue
+ try:
+ partial_path = os.path.join(entry.path, "dem.part")
+ activity_mtime = (
+ os.path.getmtime(partial_path)
+ if os.path.isfile(partial_path)
+ else entry.stat(follow_symlinks=False).st_mtime
+ )
+ if activity_mtime < cutoff:
+ shutil.rmtree(entry.path, ignore_errors=True)
+ if not os.path.exists(entry.path):
+ removed += 1
+ except FileNotFoundError:
+ pass
+ if removed:
+ logging.getLogger("riverrem.dem").info(
+ "Automatically removed %d expired DEM upload(s)", removed
+ )
+ return removed
+
+
+def _upload_dir(upload_id: str) -> str:
+ if not re.fullmatch(r"[0-9a-f]{32}", upload_id):
+ raise HTTPException(status_code=404, detail="DEM upload not found")
+ return os.path.join(DEM_UPLOAD_DIR, upload_id)
+
+
+def _resolve_dem_ref(dem_ref: str) -> str:
+ if dem_ref.startswith("upload:"):
+ if not DEM_UPLOAD_ENABLED:
+ raise ValueError("Local DEM uploads are disabled on this server")
+ upload_id = dem_ref.removeprefix("upload:")
+ if not re.fullmatch(r"[0-9a-f]{32}", upload_id):
+ raise ValueError("Invalid uploaded DEM reference")
+ target_dir = os.path.join(DEM_UPLOAD_DIR, upload_id)
+ path = os.path.join(target_dir, "dem.tif")
+ if not os.path.isfile(path):
+ raise ValueError("The uploaded DEM is unavailable or has expired")
+ if os.path.getmtime(target_dir) < time.time() - DEM_UPLOAD_TTL_HOURS * 3600:
+ shutil.rmtree(target_dir, ignore_errors=True)
+ raise ValueError("The uploaded DEM has expired")
+ return path
+ if dem_ref.startswith("library:"):
+ for candidate_ref, _, path in _safe_library_files():
+ if candidate_ref == dem_ref:
+ return path
+ raise ValueError("The selected library DEM is unavailable")
+ raise ValueError("Invalid DEM reference")
+
+
+@app.get("/capabilities")
+def capabilities():
+ return {
+ "demSources": {
+ "mapterhorn": {"enabled": True},
+ "url": {"enabled": True},
+ "upload": {
+ "enabled": DEM_UPLOAD_ENABLED,
+ "maxBytes": DEM_UPLOAD_MAX_BYTES,
+ "ttlHours": DEM_UPLOAD_TTL_HOURS,
+ "cleanupIntervalMinutes": DEM_UPLOAD_CLEANUP_INTERVAL_MINUTES,
+ },
+ "library": {
+ "enabled": bool(DEM_LIBRARY_DIR and os.path.isdir(DEM_LIBRARY_DIR)),
+ "label": DEM_LIBRARY_LABEL,
+ },
+ }
+ }
+
+
+@app.get("/dem/library")
+def dem_library():
+ if not DEM_LIBRARY_DIR or not os.path.isdir(DEM_LIBRARY_DIR):
+ raise HTTPException(status_code=404, detail="The server DEM library is disabled")
+ items = []
+ for dem_ref, relative, path in _safe_library_files():
+ try:
+ items.append(_inspect_dem(path, dem_ref=dem_ref, display_name=relative))
+ except Exception as exc:
+ logging.getLogger("riverrem.dem").warning("Skipping invalid library DEM %s: %s", relative, exc)
+ return {"items": items}
+
+
+@app.post("/dem/uploads")
+def create_dem_upload(req: DemUploadInitRequest):
+ if not DEM_UPLOAD_ENABLED:
+ raise HTTPException(status_code=404, detail="Local DEM uploads are disabled")
+ filename = os.path.basename(req.filename)
+ if not filename.lower().endswith((".tif", ".tiff")):
+ raise HTTPException(status_code=400, detail="Select a .tif or .tiff DEM")
+ if req.size_bytes > DEM_UPLOAD_MAX_BYTES:
+ raise HTTPException(status_code=413, detail="DEM exceeds this server's upload limit")
+ _prune_dem_uploads()
+ if req.size_bytes > shutil.disk_usage(DEM_UPLOAD_DIR).free:
+ raise HTTPException(status_code=507, detail="The server does not have enough free storage")
+ upload_id = uuid.uuid4().hex
+ target_dir = _upload_dir(upload_id)
+ os.makedirs(target_dir, mode=0o700)
+ with open(os.path.join(target_dir, "upload.json"), "w", encoding="utf-8") as file_obj:
+ json.dump({"filename": filename, "sizeBytes": req.size_bytes}, file_obj)
+ return {
+ "uploadId": upload_id,
+ "ref": f"upload:{upload_id}",
+ "filename": filename,
+ "sizeBytes": req.size_bytes,
+ }
+
+
+@app.put("/dem/uploads/{upload_id}")
+async def upload_dem(upload_id: str, request: Request):
+ if not DEM_UPLOAD_ENABLED:
+ raise HTTPException(status_code=404, detail="Local DEM uploads are disabled")
+ target_dir = _upload_dir(upload_id)
+ metadata_path = os.path.join(target_dir, "upload.json")
+ if not os.path.isfile(metadata_path):
+ raise HTTPException(status_code=404, detail="DEM upload not found")
+ with open(metadata_path, "r", encoding="utf-8") as file_obj:
+ metadata = json.load(file_obj)
+ expected_size = int(metadata["sizeBytes"])
+ content_length = request.headers.get("content-length")
+ if content_length and int(content_length) != expected_size:
+ raise HTTPException(status_code=400, detail="Upload size does not match the selected file")
+ partial_path = os.path.join(target_dir, "dem.part")
+ final_path = os.path.join(target_dir, "dem.tif")
+ if os.path.exists(final_path):
+ raise HTTPException(status_code=409, detail="This DEM upload is already complete")
+ received = 0
+ try:
+ with open(partial_path, "wb") as file_obj:
+ async for chunk in request.stream():
+ if not chunk:
+ continue
+ received += len(chunk)
+ if received > expected_size or received > DEM_UPLOAD_MAX_BYTES:
+ raise HTTPException(status_code=413, detail="DEM exceeds the allowed upload size")
+ file_obj.write(chunk)
+ if received != expected_size:
+ raise HTTPException(status_code=400, detail="Upload ended before the complete DEM was received")
+ info = _inspect_dem(
+ partial_path,
+ dem_ref=f"upload:{upload_id}",
+ display_name=metadata["filename"],
+ )
+ os.replace(partial_path, final_path)
+ os.utime(target_dir)
+ return info
+ except HTTPException:
+ shutil.rmtree(target_dir, ignore_errors=True)
+ raise
+ except Exception as exc:
+ shutil.rmtree(target_dir, ignore_errors=True)
+ logging.getLogger("riverrem.dem").exception("DEM upload failed")
+ raise HTTPException(status_code=400, detail=f"Could not read DEM: {exc}") from exc
+
+
@app.post("/cog/ingest", response_model=CogIngestResponse)
def cog_ingest(req: CogIngestRequest):
"""Reproject an arbitrary remote single-band float COG to a web-mercator COG.
diff --git a/backend/app/schemas.py b/backend/app/schemas.py
index 99fc8e3..367c00a 100644
--- a/backend/app/schemas.py
+++ b/backend/app/schemas.py
@@ -32,6 +32,9 @@ class ComputeRequest(BaseModel):
# Optional elevation COG to use as the DEM instead of Mapterhorn terrain tiles
# (read remotely via GDAL /vsicurl/). When set, the zoom machinery is bypassed.
source_cog_url: Optional[str] = None
+ # Opaque reference returned by the DEM upload or server-library endpoints.
+ # The API resolves this server-side; clients never submit filesystem paths.
+ source_dem_ref: Optional[str] = None
# IDW power for the river-surface interpolation (applied if the installed
# RiverREM exposes a power kwarg; otherwise parsed and ignored).
@@ -64,6 +67,13 @@ class ComputeResponse(BaseModel):
source_max_zoom: Optional[int] = None # deepest zoom Mapterhorn serves at this spot
dem_zoom: Optional[int] = None # zoom actually fetched (clamped)
requested_zoom: Optional[int] = None # screen zoom + multiplier
+ # Custom DEM size guard metadata. When downsampled is true, the UI explains
+ # that the server reduced this viewport before RiverREM processing.
+ dem_downsampled: bool = False
+ native_width: Optional[int] = None
+ native_height: Optional[int] = None
+ processed_dem_width: Optional[int] = None
+ processed_dem_height: Optional[int] = None
class PruneRequest(BaseModel):
@@ -102,3 +112,8 @@ class CogIngestResponse(BaseModel):
bounds: list[float] # full source extent in WGS84 [w, s, e, n] for fitBounds
rem_min: float
rem_max: float
+
+
+class DemUploadInitRequest(BaseModel):
+ filename: str = Field(..., min_length=1, max_length=512)
+ size_bytes: int = Field(..., gt=0)
diff --git a/backend/app/terrain.py b/backend/app/terrain.py
index 552969f..3c9f362 100644
--- a/backend/app/terrain.py
+++ b/backend/app/terrain.py
@@ -39,6 +39,10 @@
TERRAIN_ENCODING = os.environ.get("TERRAIN_ENCODING", "terrarium") # or "mapbox"
# Absolute ceiling for the per-viewport probe; Mapterhorn reaches ~18-20 in places.
TERRAIN_MAX_ZOOM = int(os.environ.get("TERRAIN_MAX_ZOOM", "20"))
+# RiverREM keeps several full-size arrays in memory. Preserve native custom-DEM
+# resolution up to this ceiling, then downsample the requested viewport before
+# computation so one high-resolution source cannot crash the API container.
+CUSTOM_DEM_MAX_PIXELS = int(os.environ.get("CUSTOM_DEM_MAX_PIXELS", "45000000"))
TILE_SIZE = 256
_UA = {"User-Agent": "riverrem-app/0.1"}
@@ -105,7 +109,14 @@ def max_available_zoom(lon: float, lat: float, ceiling: int | None = None) -> in
return _max_zoom_cached(round(lon, 3), round(lat, 3), ceiling or TERRAIN_MAX_ZOOM)
-def build_dem(bbox, zoom: int, resolution_multiplier: int, out_path: str, source_cog_url: str | None = None) -> dict:
+def build_dem(
+ bbox,
+ zoom: int,
+ resolution_multiplier: int,
+ out_path: str,
+ source_cog_url: str | None = None,
+ source_dem_path: str | None = None,
+) -> dict:
"""Fetch terrain tiles for `bbox`, decode, mosaic, reproject to UTM, write GeoTIFF.
If `source_cog_url` is given, that elevation COG is used as the DEM instead of
@@ -124,22 +135,65 @@ def build_dem(bbox, zoom: int, resolution_multiplier: int, out_path: str, source
utm_zone = int((cx0 + 180) / 6) + 1
epsg_utm = (32600 if cy0 >= 0 else 32700) + utm_zone
- if source_cog_url:
- src = source_cog_url
- if src.startswith("http://") or src.startswith("https://"):
+ if source_cog_url and source_dem_path:
+ raise ValueError("Choose either a source COG URL or a server DEM reference, not both")
+
+ if source_cog_url or source_dem_path:
+ src = source_dem_path or source_cog_url
+ if source_cog_url and (src.startswith("http://") or src.startswith("https://")):
src = "/vsicurl/" + src
- _log.info("DEM build: using provided source COG %s", source_cog_url)
- gdal.Warp(
- out_path, src,
+ _log.info("DEM build: using provided source %s", source_cog_url or "server DEM")
+ warp_args = dict(
dstSRS=f"EPSG:{epsg_utm}",
resampleAlg="bilinear",
outputBounds=(bbox.west, bbox.south, bbox.east, bbox.north),
outputBoundsSRS="EPSG:4326",
dstNodata=-9999.0,
+ )
+
+ # Ask GDAL for the native-resolution output shape without materializing
+ # its pixels. A 0.5 m DEM over a large viewport can otherwise create
+ # hundreds of millions of cells and exhaust RiverREM's working memory.
+ preview_path = f"/vsimem/riverrem-dem-preview-{time.time_ns()}.vrt"
+ preview = None
+ try:
+ preview = gdal.Warp(preview_path, src, format="VRT", **warp_args)
+ if preview is None or preview.RasterXSize < 1 or preview.RasterYSize < 1:
+ raise ValueError("The selected DEM does not overlap the map viewport")
+ native_width, native_height = preview.RasterXSize, preview.RasterYSize
+ finally:
+ preview = None
+ gdal.Unlink(preview_path)
+
+ width, height = native_width, native_height
+ native_pixels = width * height
+ if CUSTOM_DEM_MAX_PIXELS > 0 and native_pixels > CUSTOM_DEM_MAX_PIXELS:
+ scale = math.sqrt(native_pixels / CUSTOM_DEM_MAX_PIXELS)
+ width = max(1, round(width / scale))
+ height = max(1, round(height / scale))
+ _log.warning(
+ "Custom DEM viewport is %dx%d (%.1fM cells); downsampling to "
+ "%dx%d (%.1fM cells) for safe processing",
+ native_width, native_height, native_pixels / 1_000_000,
+ width, height, width * height / 1_000_000,
+ )
+
+ output = gdal.Warp(
+ out_path, src,
+ width=width, height=height,
+ outputType=gdal.GDT_Float32,
+ creationOptions=["TILED=YES", "COMPRESS=DEFLATE", "BIGTIFF=IF_SAFER"],
format="GTiff",
+ **warp_args,
)
+ if output is None:
+ raise ValueError("Could not prepare the selected DEM for this viewport")
+ output = None
return {"path": out_path, "source_max_zoom": None, "dem_zoom": None,
- "requested_zoom": None, "screen_zoom": zoom}
+ "requested_zoom": None, "screen_zoom": zoom,
+ "native_width": native_width, "native_height": native_height,
+ "dem_width": width, "dem_height": height,
+ "dem_downsampled": (width, height) != (native_width, native_height)}
# want_z = the resolution requested (screen zoom + multiplier); source_max = the
# deepest zoom Mapterhorn serves here; z = the clamp (no upsampling past source).
diff --git a/backend/tests/__init__.py b/backend/tests/__init__.py
new file mode 100644
index 0000000..e69de29
diff --git a/backend/tests/test_centerline_import.py b/backend/tests/test_centerline_import.py
new file mode 100644
index 0000000..031005b
--- /dev/null
+++ b/backend/tests/test_centerline_import.py
@@ -0,0 +1,188 @@
+import json
+import os
+import tempfile
+import unittest
+import zipfile
+
+import geopandas as gpd
+from osgeo import gdal, osr
+from shapely.geometry import LineString
+
+from app.centerline import CenterlineCrsRequired, geojson_to_shapefile, import_centerline_dataset
+from app.main import _normalize_geojson_document, _preflight_custom_dem, _safe_extract_shapefile_zip
+from app.schemas import BBox, ComputeRequest
+
+
+class CenterlineImportTests(unittest.TestCase):
+ def test_geojson_is_assumed_wgs84(self):
+ with tempfile.TemporaryDirectory() as temp_dir:
+ path = os.path.join(temp_dir, "river.geojson")
+ with open(path, "w", encoding="utf-8") as file_obj:
+ json.dump({
+ "type": "FeatureCollection",
+ "features": [{
+ "type": "Feature",
+ "properties": {},
+ "geometry": {
+ "type": "LineString",
+ "coordinates": [[-90.86, 38.64], [-90.80, 38.65]],
+ },
+ }],
+ }, file_obj)
+ layers = import_centerline_dataset(
+ path, display_name="river.geojson", force_geojson_wgs84=True
+ )
+ self.assertEqual(layers[0]["crs"], "EPSG:4326")
+ self.assertEqual(layers[0]["featureCount"], 1)
+
+ def test_projected_geojson_is_rejected(self):
+ with tempfile.TemporaryDirectory() as temp_dir:
+ path = os.path.join(temp_dir, "projected.geojson")
+ gpd.GeoDataFrame(
+ geometry=[LineString([(685500, 4282500), (686000, 4282000)])],
+ crs="EPSG:6344",
+ ).to_file(path, driver="GeoJSON")
+ with self.assertRaisesRegex(ValueError, "EPSG:4326"):
+ import_centerline_dataset(
+ path, display_name="projected.geojson", force_geojson_wgs84=True
+ )
+
+ def test_bare_geojson_line_is_normalized(self):
+ with tempfile.TemporaryDirectory() as temp_dir:
+ source = os.path.join(temp_dir, "line.json")
+ normalized = os.path.join(temp_dir, "normalized.geojson")
+ with open(source, "w", encoding="utf-8") as file_obj:
+ json.dump({
+ "type": "LineString",
+ "coordinates": [[-90.86, 38.64], [-90.80, 38.65]],
+ }, file_obj)
+ _normalize_geojson_document(source, normalized)
+ layers = import_centerline_dataset(
+ normalized, display_name="line.json", force_geojson_wgs84=True
+ )
+ self.assertEqual(layers[0]["featureCount"], 1)
+
+ def test_geopackage_layers_are_reprojected(self):
+ with tempfile.TemporaryDirectory() as temp_dir:
+ path = os.path.join(temp_dir, "rivers.gpkg")
+ first = gpd.GeoDataFrame(
+ {"name": ["one"]},
+ geometry=[LineString([(685500, 4282500), (686000, 4282000)])],
+ crs="EPSG:6344",
+ )
+ second = gpd.GeoDataFrame(
+ {"name": ["two"]},
+ geometry=[LineString([(686000, 4282000), (686500, 4281500)])],
+ crs="EPSG:6344",
+ )
+ first.to_file(path, layer="main", driver="GPKG")
+ second.to_file(path, layer="tributary", driver="GPKG", mode="a")
+ layers = import_centerline_dataset(path, display_name="rivers.gpkg")
+ self.assertEqual({layer["name"] for layer in layers}, {"main", "tributary"})
+ self.assertTrue(all(layer["crs"] == "EPSG:6344" for layer in layers))
+ longitude = layers[0]["geojson"]["features"][0]["geometry"]["coordinates"][0][0]
+ self.assertTrue(-180 <= longitude <= 180)
+
+ def test_missing_shapefile_crs_can_be_supplied(self):
+ with tempfile.TemporaryDirectory() as temp_dir:
+ path = os.path.join(temp_dir, "river.shp")
+ gpd.GeoDataFrame(
+ geometry=[LineString([(685500, 4282500), (686000, 4282000)])]
+ ).to_file(path)
+ with self.assertRaises(CenterlineCrsRequired):
+ import_centerline_dataset(path, display_name="river.shp")
+ layers = import_centerline_dataset(
+ path, display_name="river.shp", input_crs="EPSG:6344"
+ )
+ self.assertEqual(layers[0]["crs"], "EPSG:6344")
+
+ def test_zip_traversal_is_rejected(self):
+ with tempfile.TemporaryDirectory() as temp_dir:
+ archive_path = os.path.join(temp_dir, "unsafe.zip")
+ with zipfile.ZipFile(archive_path, "w") as archive:
+ archive.writestr("../river.shp", b"unsafe")
+ with self.assertRaisesRegex(ValueError, "unsafe path"):
+ _safe_extract_shapefile_zip(archive_path, os.path.join(temp_dir, "out"))
+
+ def test_macos_zip_metadata_is_ignored(self):
+ with tempfile.TemporaryDirectory() as temp_dir:
+ source_dir = os.path.join(temp_dir, "source")
+ os.makedirs(source_dir)
+ shapefile_path = os.path.join(source_dir, "river.shp")
+ gpd.GeoDataFrame(
+ geometry=[LineString([(685500, 4282500), (686000, 4282000)])],
+ crs="EPSG:6344",
+ ).to_file(shapefile_path)
+
+ archive_path = os.path.join(temp_dir, "finder.zip")
+ with zipfile.ZipFile(archive_path, "w") as archive:
+ for filename in os.listdir(source_dir):
+ archive.write(os.path.join(source_dir, filename), filename)
+ archive.writestr(f"__MACOSX/._{filename}", b"AppleDouble metadata")
+
+ extract_dir = os.path.join(temp_dir, "out")
+ extracted = _safe_extract_shapefile_zip(archive_path, extract_dir)
+ self.assertEqual([os.path.basename(path) for path in extracted], ["river.shp"])
+ layers = import_centerline_dataset(extracted[0], display_name="river.shp")
+ self.assertEqual(layers[0]["crs"], "EPSG:6344")
+
+ def test_incomplete_shapefile_is_rejected(self):
+ with tempfile.TemporaryDirectory() as temp_dir:
+ archive_path = os.path.join(temp_dir, "incomplete.zip")
+ with zipfile.ZipFile(archive_path, "w") as archive:
+ archive.writestr("river.shp", b"manifest validation")
+ with self.assertRaisesRegex(ValueError, r"missing \.dbf and \.shx"):
+ _safe_extract_shapefile_zip(archive_path, os.path.join(temp_dir, "out"))
+
+ def test_non_line_features_are_reported(self):
+ with tempfile.TemporaryDirectory() as temp_dir:
+ path = os.path.join(temp_dir, "mixed.geojson")
+ with open(path, "w", encoding="utf-8") as file_obj:
+ json.dump({
+ "type": "FeatureCollection",
+ "features": [
+ {"type": "Feature", "properties": {}, "geometry": {
+ "type": "LineString", "coordinates": [[-90.9, 38.5], [-90.8, 38.6]],
+ }},
+ {"type": "Feature", "properties": {}, "geometry": {
+ "type": "Point", "coordinates": [-90.85, 38.55],
+ }},
+ ],
+ }, file_obj)
+ layers = import_centerline_dataset(
+ path, display_name="mixed.geojson", force_geojson_wgs84=True
+ )
+ self.assertEqual(layers[0]["featureCount"], 1)
+ self.assertEqual(layers[0]["vertexCount"], 2)
+ self.assertIn("Skipped 1 non-line feature.", layers[0]["warnings"])
+
+ def test_custom_dem_centerline_overlap_preflight(self):
+ with tempfile.TemporaryDirectory() as temp_dir:
+ dem_path = os.path.join(temp_dir, "dem.tif")
+ dataset = gdal.GetDriverByName("GTiff").Create(dem_path, 10, 10, 1, gdal.GDT_Float32)
+ dataset.SetGeoTransform((-91.0, 0.1, 0.0, 39.0, 0.0, -0.1))
+ spatial_ref = osr.SpatialReference()
+ spatial_ref.ImportFromEPSG(4326)
+ dataset.SetProjection(spatial_ref.ExportToWkt())
+ dataset.GetRasterBand(1).Fill(100)
+ dataset = None
+
+ request = ComputeRequest(
+ bbox=BBox(west=-91.0, south=38.0, east=-90.0, north=39.0),
+ source_dem_ref="library:test",
+ centerline_mode="geojson",
+ )
+ matching = geojson_to_shapefile({
+ "type": "LineString", "coordinates": [[-90.9, 38.5], [-90.8, 38.6]],
+ }, os.path.join(temp_dir, "matching.shp"))
+ _preflight_custom_dem(request, dem_path, matching)
+
+ outside = geojson_to_shapefile({
+ "type": "LineString", "coordinates": [[-80.9, 38.5], [-80.8, 38.6]],
+ }, os.path.join(temp_dir, "outside.shp"))
+ with self.assertRaisesRegex(ValueError, "centerline does not overlap"):
+ _preflight_custom_dem(request, dem_path, outside)
+
+
+if __name__ == "__main__":
+ unittest.main()
diff --git a/docker-compose.local.yml b/docker-compose.local.yml
index ef55f42..decc29e 100644
--- a/docker-compose.local.yml
+++ b/docker-compose.local.yml
@@ -10,8 +10,20 @@ services:
environment:
PUBLIC_BASE: http://localhost:${WEB_PORT:-8088}
DATA_DIR: /data
+ CENTERLINE_UPLOAD_MAX_BYTES: ${CENTERLINE_UPLOAD_MAX_BYTES:-67108864}
+ CENTERLINE_MAX_FEATURES: ${CENTERLINE_MAX_FEATURES:-10000}
+ CENTERLINE_MAX_VERTICES: ${CENTERLINE_MAX_VERTICES:-500000}
+ DEM_UPLOAD_ENABLED: ${DEM_UPLOAD_ENABLED:-false}
+ DEM_UPLOAD_MAX_BYTES: ${DEM_UPLOAD_MAX_BYTES:-21474836480}
+ DEM_UPLOAD_TTL_HOURS: ${DEM_UPLOAD_TTL_HOURS:-24}
+ DEM_UPLOAD_CLEANUP_INTERVAL_MINUTES: ${DEM_UPLOAD_CLEANUP_INTERVAL_MINUTES:-15}
+ DEM_LIBRARY_DIR: ${DEM_LIBRARY_DIR:-}
+ DEM_LIBRARY_LABEL: ${DEM_LIBRARY_LABEL:-Server library}
+ CUSTOM_DEM_MAX_PIXELS: ${CUSTOM_DEM_MAX_PIXELS:-45000000}
volumes:
- cogs:/data
+ # Set DEM_LIBRARY_DIR=/dem-library to expose this read-only mount.
+ - ${DEM_LIBRARY_HOST_DIR:-./dem-library}:/dem-library:ro
restart: unless-stopped
web:
diff --git a/docker-compose.yml b/docker-compose.yml
index 9e1514b..98da22e 100644
--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -23,8 +23,19 @@ services:
PUBLIC_BASE: ${PUBLIC_BASE:-https://rem.example.com}
DATA_DIR: /data
MAX_COGS: ${MAX_COGS:-200}
+ CENTERLINE_UPLOAD_MAX_BYTES: ${CENTERLINE_UPLOAD_MAX_BYTES:-67108864}
+ CENTERLINE_MAX_FEATURES: ${CENTERLINE_MAX_FEATURES:-10000}
+ CENTERLINE_MAX_VERTICES: ${CENTERLINE_MAX_VERTICES:-500000}
+ DEM_UPLOAD_ENABLED: ${DEM_UPLOAD_ENABLED:-false}
+ DEM_UPLOAD_MAX_BYTES: ${DEM_UPLOAD_MAX_BYTES:-21474836480}
+ DEM_UPLOAD_TTL_HOURS: ${DEM_UPLOAD_TTL_HOURS:-24}
+ DEM_UPLOAD_CLEANUP_INTERVAL_MINUTES: ${DEM_UPLOAD_CLEANUP_INTERVAL_MINUTES:-15}
+ DEM_LIBRARY_DIR: ${DEM_LIBRARY_DIR:-}
+ DEM_LIBRARY_LABEL: ${DEM_LIBRARY_LABEL:-Server library}
+ CUSTOM_DEM_MAX_PIXELS: ${CUSTOM_DEM_MAX_PIXELS:-45000000}
volumes:
- cogs:/data # generated REM/DEM COGs persist here
+ - ${DEM_LIBRARY_HOST_DIR:-./dem-library}:/dem-library:ro
networks: [internal]
restart: unless-stopped
diff --git a/docs/centerline-imports.md b/docs/centerline-imports.md
new file mode 100644
index 0000000..244cba4
--- /dev/null
+++ b/docs/centerline-imports.md
@@ -0,0 +1,40 @@
+# Centerline file imports
+
+The **Upload Centerline File** control accepts:
+
+- `.geojson` or `.json`: assumed to be RFC 7946 WGS84 (`EPSG:4326`) and rejected
+ when coordinates fall outside longitude/latitude ranges;
+- `.gpkg`: line layers are read with their embedded CRS;
+- `.zip`: a zipped shapefile containing `.shp`, `.shx`, `.dbf`, and preferably
+ `.prj`. Archives are checked for traversal, symbolic links, file count, and
+ expanded size before extraction.
+
+The API keeps only `LineString` and `MultiLineString` features and converts them
+to WGS84 GeoJSON for preview in MapLibre. If a GeoPackage or shapefile has no CRS,
+the UI asks for an authority code such as `EPSG:6344` and retries with that CRS.
+When a file contains multiple line layers, the UI provides a layer selector and
+shows the selected layer's source CRS, feature count, and approximate length.
+
+Uploads are processed in a temporary server directory and the original
+centerline file is deleted when the request finishes. The normalized GeoJSON is
+sent back to the browser and later included in the compute request. RiverREM
+reprojects that normalized centerline into the prepared DEM CRS before sampling.
+
+The default upload limit is 64 MiB and can be changed with:
+
+```dotenv
+CENTERLINE_UPLOAD_MAX_BYTES=67108864
+CENTERLINE_MAX_FEATURES=10000
+CENTERLINE_MAX_VERTICES=500000
+```
+
+Keep nginx's `client_max_body_size` for the centerline route aligned if this
+limit is increased.
+
+The importer also verifies zipped Shapefile component sets, ignores common
+macOS metadata, filters empty, invalid, and non-line geometry with visible
+warnings, and rejects unusually complex layers before they consume excessive
+memory. For fixed-extent custom DEMs (URL, upload, or server library), compute
+checks geographic overlap with the viewport and selected centerline before
+warping the DEM or starting RiverREM. Mapterhorn is generated for the viewport
+and does not require this fixed-extent check.
diff --git a/docs/dem-sources.md b/docs/dem-sources.md
new file mode 100644
index 0000000..9900657
--- /dev/null
+++ b/docs/dem-sources.md
@@ -0,0 +1,80 @@
+# Custom DEM sources
+
+The server engine always supports Mapterhorn and a remote COG URL. It can also
+offer two opt-in sources:
+
+- **Upload local DEM** streams a browser-selected `.tif` or `.tiff` directly to
+ the API. nginx does not buffer the whole request, and the API enforces the
+ configured size limit while writing it to the persistent `/data` volume.
+- **Server library** lists valid GeoTIFFs from a server-owner-controlled,
+ read-only directory. The browser receives opaque references, never filesystem
+ paths. The API rejects references outside the configured directory.
+
+The frontend reads `GET /capabilities`, so disabled choices are not displayed.
+The backend also enforces every setting; hiding a control is not the security
+boundary. Both optional sources are disabled by default, including on public
+deployments.
+
+## Enable streamed uploads
+
+In `.env`:
+
+```dotenv
+DEM_UPLOAD_ENABLED=true
+DEM_UPLOAD_MAX_BYTES=21474836480
+DEM_UPLOAD_TTL_HOURS=24
+DEM_UPLOAD_CLEANUP_INTERVAL_MINUTES=15
+```
+
+Then rebuild/restart the stack:
+
+```sh
+docker compose -f docker-compose.local.yml up --build
+```
+
+The default maximum is 20 GiB. nginx limits this route to 20 GiB too; if you
+change the API maximum, keep `client_max_body_size` in `frontend/nginx.conf`
+aligned. Incomplete or invalid uploads are removed, valid uploads are checked by
+GDAL, and expired upload directories are automatically pruned at server startup
+and every 15 minutes by default. No administrator cleanup is required. Files live
+in the Compose `cogs` volume under `/data/dem_uploads`. For a public server,
+consider authentication and rate limiting before enabling uploads for untrusted
+users.
+
+RiverREM holds several complete raster arrays in memory. To keep a very large or
+high-resolution custom DEM from exhausting the API container, the server keeps
+native resolution up to `CUSTOM_DEM_MAX_PIXELS` (45 million by default) within
+the selected viewport and downsamples larger windows before processing. Set this
+to a different positive value only if the server has enough RAM; `0` disables
+the guard and is not recommended for public deployments.
+
+## Enable a server library
+
+Create a directory on the Docker host and place georeferenced `.tif`/`.tiff`
+DEMs inside it (nested directories are supported). Configure its host path and
+the fixed read-only container path:
+
+```dotenv
+DEM_LIBRARY_HOST_DIR=/absolute/host/path/to/dems
+DEM_LIBRARY_DIR=/dem-library
+DEM_LIBRARY_LABEL=Available DEMs
+```
+
+Restart the stack. The option appears only when the in-container directory
+exists. The Compose mount is read-only (`:ro`), and symlinks or paths resolving
+outside the library root are excluded.
+
+This works on localhost and online: on a public site, the library contains only
+files deliberately installed by that server's owner. To hide the option again,
+leave `DEM_LIBRARY_DIR` blank and restart.
+
+## Source selection API
+
+`POST /compute` accepts one of:
+
+- neither field: use Mapterhorn;
+- `source_cog_url`: use a remote COG;
+- `source_dem_ref`: use a reference returned by the upload or library API.
+
+Sending both custom-source fields is rejected. Client-provided filesystem paths
+are never accepted.
diff --git a/frontend/nginx.conf b/frontend/nginx.conf
index 5951f70..6583873 100644
--- a/frontend/nginx.conf
+++ b/frontend/nginx.conf
@@ -7,7 +7,21 @@ server {
# Backend API + the persistent COG store, proxied on the SAME origin as the app
# (so share links + the MapLibre COG protocol work with no CORS, no extra subdomain).
# COGs are streamed with HTTP range requests (206); buffering off keeps that intact.
- location ~ ^/(cogs|cog|compute|centerline|upload|sample|health|thumb|runs|gallery)(/|$) {
+ # Large GeoTIFFs are allowed only on the DEM upload API. Stream them directly
+ # to FastAPI; it enforces DEM_UPLOAD_MAX_BYTES while writing the request.
+ location ~ ^/dem/uploads(/|$) {
+ proxy_pass http://api:8000;
+ proxy_http_version 1.1;
+ proxy_set_header Host $host;
+ proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
+ proxy_set_header X-Forwarded-Proto $scheme;
+ proxy_buffering off;
+ proxy_request_buffering off;
+ proxy_read_timeout 600s;
+ client_max_body_size 20g;
+ }
+
+ location ~ ^/(cogs|cog|compute|centerline|upload|sample|health|thumb|runs|gallery|capabilities|dem)(/|$) {
proxy_pass http://api:8000;
proxy_http_version 1.1;
proxy_set_header Host $host;
diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx
index f8fbfaa..b1ae780 100644
--- a/frontend/src/App.tsx
+++ b/frontend/src/App.tsx
@@ -3,7 +3,7 @@ import type { Map as MlMap } from "maplibre-gl";
import { MapView } from "@/components/MapView";
import { SidePanel } from "@/components/SidePanel";
import { useMapView, useRemOptions, useActiveRem, useUiState } from "@/lib/state";
-import { api, cogPath, geocode, reverseGeocode, type BBox, type ComputeResponse, type GeoHit, type GalleryItem } from "@/lib/api";
+import { api, cogPath, geocode, reverseGeocode, CenterlineImportError, type BBox, type CenterlineImportLayer, type ComputeResponse, type DemCapabilities, type DemItem, type GeoHit, type GalleryItem } from "@/lib/api";
import { fetchLongestRiver, fetchAllRivers, fetchAllWaterways, mergeFeatureCollection, OVERPASS_PRESETS } from "@/lib/osm";
import { sampleRiverPoints, setRemParams, packPts, unpackPts, probeMaxZoom, sampleAt, sampleDemBounds, getRemPerfStats, exportRemCog, exportDemCog, type RiverPoint, type RemPerfStats } from "@/lib/remClient";
import { listRuns, addRun, removeRun, updateRun, pruneRuns, type Run } from "@/lib/history";
@@ -23,7 +23,8 @@ function downloadUrl(url: string, name: string) {
// Map backend phase + RiverREM % to a smooth 0–100 (fake ~20%/step, real % in interp).
function displayPct(phase: string, pct: number): number {
const m: Record = {
- Queued: 5, "Fetching terrain tiles": 5, "Resolving centerline": 10,
+ Queued: 5, "Resolving centerline": 7, "Checking custom DEM coverage": 9,
+ "Fetching terrain tiles": 10, "Preparing server DEM": 10, "Reading DEM COG": 10,
"Running RiverREM": 15, "Finding centerline": 20, "Sampling river elevation": 25,
"Detrending DEM": 90, "Building COG": 95, Done: 100,
};
@@ -34,6 +35,10 @@ function displayPct(phase: string, pct: number): number {
// When the requested zoom exceeds Mapterhorn's deepest zoom here, the multiplier is
// capped (we never upsample past the source). Tell the user the ceiling.
function resolutionNote(res: ComputeResponse, screenZoom: number, reqMult: number): string | null {
+ if (res.dem_downsampled && res.native_width && res.native_height &&
+ res.processed_dem_width && res.processed_dem_height) {
+ return `This custom DEM viewport was reduced from ${res.native_width.toLocaleString()}×${res.native_height.toLocaleString()} to ${res.processed_dem_width.toLocaleString()}×${res.processed_dem_height.toLocaleString()} pixels to fit the server's processing limit.`;
+ }
const smz = res.source_max_zoom, rz = res.requested_zoom, dz = res.dem_zoom;
if (smz == null || rz == null || dz == null) return null;
// Only relevant when the user asked to oversample (>1×) AND the multiplier was
@@ -44,6 +49,15 @@ function resolutionNote(res: ComputeResponse, screenZoom: number, reqMult: numbe
return `Mapterhorn's deepest zoom here is z${smz}, so ${reqMult}× was capped to ${maxMult}× (fetched z${dz}). Zoom the map out to oversample further.`;
}
+const DEFAULT_DEM_CAPABILITIES: DemCapabilities = {
+ demSources: {
+ mapterhorn: { enabled: true },
+ url: { enabled: true },
+ upload: { enabled: false, maxBytes: 0, ttlHours: 24, cleanupIntervalMinutes: 15 },
+ library: { enabled: false, label: "Server library" },
+ },
+};
+
/** Clip a GeoJSON centerline to a bounding box expanded by bufferPct on each side. */
function cropCenterline(geojson: GeoJSON.GeoJSON | null, bbox: BBox, bufferPct = 0.1): GeoJSON.GeoJSON | null {
if (!geojson) return null;
@@ -83,6 +97,13 @@ export default function App() {
const [result, setResult] = useState(null);
const [centerline, setCenterline] = useState(null);
+ const [centerlineFileName, setCenterlineFileName] = useState(null);
+ const [centerlineLayers, setCenterlineLayers] = useState([]);
+ const [selectedCenterlineLayerId, setSelectedCenterlineLayerId] = useState("");
+ const [centerlineImportBusy, setCenterlineImportBusy] = useState(false);
+ const [centerlineCrsRequired, setCenterlineCrsRequired] = useState(false);
+ const [centerlineInputCrs, setCenterlineInputCrs] = useState("");
+ const [pendingCenterlineFile, setPendingCenterlineFile] = useState(null);
const [centerInfo, setCenterInfo] = useState<{ river_name: string; river_length_m: number } | null>(null);
const [uploadId, setUploadId] = useState(null);
const [busy, setBusy] = useState(false);
@@ -145,10 +166,55 @@ export default function App() {
const [fps, setFps] = useState(null);
const [remPerf, setRemPerf] = useState(null);
const [demCogUrl, setDemCogUrl] = useState(""); // optional DEM COG for the server engine
+ const [demCapabilities, setDemCapabilities] = useState(DEFAULT_DEM_CAPABILITIES);
+ const [demSourceMode, setDemSourceMode] = useState<"mapterhorn" | "url" | "upload" | "library">("mapterhorn");
+ const [uploadedDem, setUploadedDem] = useState(null);
+ const [demUploadBusy, setDemUploadBusy] = useState(false);
+ const [demUploadProgress, setDemUploadProgress] = useState(0);
+ const [demLibrary, setDemLibrary] = useState([]);
+ const [libraryDemRef, setLibraryDemRef] = useState("");
const [clientMaxZoom, setClientMaxZoom] = useState(14); // probed deepest Mapterhorn zoom (client engine)
const [previewBusy, setPreviewBusy] = useState(false);
const [serverRunsLoading, setServerRunsLoading] = useState(false);
+ useEffect(() => {
+ let cancelled = false;
+ api.capabilities().then(async (capabilities) => {
+ if (cancelled) return;
+ setDemCapabilities(capabilities);
+ if (capabilities.demSources.library.enabled) {
+ try {
+ const { items } = await api.demLibrary();
+ if (!cancelled) setDemLibrary(items);
+ } catch { if (!cancelled) setDemLibrary([]); }
+ }
+ }).catch(() => {
+ // Older backends do not expose capabilities. Preserve their Mapterhorn/URL UI.
+ if (!cancelled) setDemCapabilities(DEFAULT_DEM_CAPABILITIES);
+ });
+ return () => { cancelled = true; };
+ }, []);
+
+ const onUploadDem = useCallback(async (file: File) => {
+ if (!demCapabilities.demSources.upload.enabled) return;
+ if (!file.name.toLowerCase().endsWith(".tif") && !file.name.toLowerCase().endsWith(".tiff")) {
+ alert("Select a .tif or .tiff DEM file.");
+ return;
+ }
+ if (file.size > demCapabilities.demSources.upload.maxBytes) {
+ alert("This DEM is larger than the server's configured upload limit.");
+ return;
+ }
+ setDemUploadBusy(true); setDemUploadProgress(0); setUploadedDem(null);
+ try {
+ const initialized = await api.initDemUpload(file);
+ const item = await api.uploadDem(initialized.uploadId, file, setDemUploadProgress);
+ setUploadedDem(item); setDemUploadProgress(100);
+ } catch (error) {
+ alert(`DEM upload failed: ${(error as Error).message}`);
+ } finally { setDemUploadBusy(false); }
+ }, [demCapabilities]);
+
const bboxRef = useRef<{ bbox: BBox; zoom: number } | null>(null);
const resultRef = useRef(null);
useEffect(() => { resultRef.current = result; }, [result]);
@@ -353,6 +419,11 @@ export default function App() {
setCenterline(r.geojson);
setCenterInfo({ river_name: r.name, river_length_m: r.length_m });
}
+ setCenterlineFileName(null);
+ setCenterlineLayers([]);
+ setSelectedCenterlineLayerId("");
+ setCenterlineCrsRequired(false);
+ setPendingCenterlineFile(null);
lastCenterlineFetchRef.current = { bbox, qleverMode: opts.qleverMode };
} catch (e) {
if ((e as Error).name !== "AbortError") alert(`No river found: ${(e as Error).message}`);
@@ -362,88 +433,101 @@ export default function App() {
}
}, [opts.osm, opts.qleverMode, opts.polyWater, opts.qleverGroupBy]);
- const onDrawn = useCallback((g: GeoJSON.GeoJSON) => { setCenterline(mergeFeatureCollection(g)); setCenterInfo(null); }, []);
+ const onDrawn = useCallback((g: GeoJSON.GeoJSON) => {
+ setCenterline(mergeFeatureCollection(g));
+ setCenterlineFileName(null);
+ setCenterlineLayers([]);
+ setSelectedCenterlineLayerId("");
+ setCenterlineCrsRequired(false);
+ setPendingCenterlineFile(null);
+ setCenterInfo(null);
+ }, []);
const onCropToViewport = useCallback(() => {
if (!bboxRef.current || !centerline) return;
const cropped = cropCenterline(centerline, bboxRef.current.bbox, 0.1);
if (cropped) setCenterline(cropped);
}, [centerline]);
- const onImport = useCallback(async (f: File) => {
- setBusy(true);
- try {
- const text = await f.text();
- console.log("[import] raw text length:", text.length, "file:", f.name);
- const parsed = JSON.parse(text) as GeoJSON.GeoJSON;
- console.log("[import] parsed type:", parsed.type);
-
- const fc: GeoJSON.FeatureCollection =
- parsed.type === "FeatureCollection" ? parsed :
- parsed.type === "Feature" ? { type: "FeatureCollection", features: [parsed as GeoJSON.Feature] } :
- { type: "FeatureCollection", features: [] };
-
- const lineFeatures = fc.features.filter(
- (f) => f.geometry?.type === "LineString" || f.geometry?.type === "MultiLineString",
- );
- console.log("[import] total features:", fc.features.length, "| line features:", lineFeatures.length,
- lineFeatures.map((f) => `${f.geometry.type}(${f.geometry.type === "LineString" ? (f.geometry as GeoJSON.LineString).coordinates.length : "multi"} pts)`));
-
- // Collect all coordinates for bbox calculation.
- const coords: number[][] = [];
- for (const feat of lineFeatures) {
- const g = feat.geometry;
- if (g.type === "LineString") coords.push(...g.coordinates);
- else if (g.type === "MultiLineString") for (const ls of g.coordinates) coords.push(...ls);
- }
+ const applyImportedLayer = useCallback((layer: CenterlineImportLayer, filename: string) => {
+ const lineFeatures = layer.geojson.features.filter(
+ (feature) => feature.geometry?.type === "LineString" || feature.geometry?.type === "MultiLineString",
+ );
+ const importedFc: GeoJSON.FeatureCollection = { type: "FeatureCollection", features: lineFeatures };
+ const coords: number[][] = [];
+ for (const feature of lineFeatures) {
+ const geometry = feature.geometry;
+ if (geometry.type === "LineString") coords.push(...geometry.coordinates);
+ else if (geometry.type === "MultiLineString") for (const line of geometry.coordinates) coords.push(...line);
+ }
- // Load into terra-draw so it owns the geometry and draws it on the map.
- // Falls back to plain preview-line if terra-draw is not yet ready.
- const draw = terradrawRef.current;
- if (draw) {
- try {
- draw.clear();
- const tdFeatures = lineFeatures.map((feat) => ({
- ...feat,
- id: crypto.randomUUID(),
- properties: { ...(feat.properties ?? {}), mode: "linestring" },
- }));
- const results = draw.addFeatures(tdFeatures);
- console.log("[import] terra-draw addFeatures results:", results);
- const snapshot: GeoJSON.Feature[] = draw.getSnapshot();
- console.log("[import] terra-draw snapshot after add:", snapshot.length, "features");
- const importedFc: GeoJSON.FeatureCollection = { type: "FeatureCollection", features: snapshot };
- setCenterline(importedFc);
- } catch (err) {
- console.warn("[import] terra-draw addFeatures failed, falling back to plain preview:", err);
- setCenterline(fc);
- }
- } else {
- console.warn("[import] terra-draw not ready, using plain preview-line");
- setCenterline(fc);
+ // TerraDraw may adopt the normalized WGS84 features for editing, but the
+ // returned GeoJSON remains authoritative for preview and computation.
+ const draw = terradrawRef.current;
+ if (draw) {
+ try {
+ draw.clear();
+ draw.addFeatures(lineFeatures.map((feature) => ({
+ ...feature,
+ id: crypto.randomUUID(),
+ properties: { ...(feature.properties ?? {}), mode: "linestring" },
+ })));
+ } catch (error) {
+ console.warn("[import] TerraDraw could not adopt imported features; using preview layer:", error);
}
+ }
+
+ setCenterline(importedFc);
+ setCenterlineFileName(filename);
+ setSelectedCenterlineLayerId(layer.id);
+ setUploadId(null);
+ setCenterInfo(null);
+ setResult(null); setRiverPoints(null); setActiveRunId(null);
+ setOpts({ mode: "geojson", showContours: false, showSamples: false });
+
+ if (coords.length > 0 && mapRef.current) {
+ const lngs = coords.map((coordinate) => coordinate[0]);
+ const lats = coords.map((coordinate) => coordinate[1]);
+ mapRef.current.fitBounds(
+ [Math.min(...lngs), Math.min(...lats), Math.max(...lngs), Math.max(...lats)],
+ { padding: 60, duration: 600, maxZoom: 14 },
+ );
+ }
+ }, [setOpts]);
- setUploadId(null);
- setCenterInfo(null);
- setOpts({ mode: "geojson" });
-
- // Fit map to the imported geometry.
- if (coords.length > 0 && mapRef.current) {
- const lngs = coords.map((c) => c[0]);
- const lats = coords.map((c) => c[1]);
- const bounds: [number, number, number, number] = [
- Math.min(...lngs), Math.min(...lats), Math.max(...lngs), Math.max(...lats),
- ];
- console.log("[import] fitting map to bounds:", bounds);
- mapRef.current.fitBounds(bounds, { padding: 60, duration: 600, maxZoom: 14 });
+ const onImport = useCallback(async (file: File, inputCrs?: string) => {
+ setCenterlineImportBusy(true);
+ setCenterlineCrsRequired(false);
+ setPendingCenterlineFile(file);
+ try {
+ const imported = await api.importCenterline(file, inputCrs);
+ if (imported.layers.length === 0) throw new Error("No line layers were found.");
+ setCenterlineLayers(imported.layers);
+ setCenterlineInputCrs("");
+ setPendingCenterlineFile(null);
+ applyImportedLayer(imported.layers[0], imported.filename);
+ } catch (error) {
+ if (error instanceof CenterlineImportError && error.code === "crs_required") {
+ setCenterlineCrsRequired(true);
} else {
- console.warn("[import] no coordinates found for fitBounds");
+ console.error("[import] failed:", error);
+ setPendingCenterlineFile(null);
+ alert(`Import failed: ${(error as Error).message}`);
}
- } catch (e) {
- console.error("[import] failed:", e);
- alert(`Import failed: ${(e as Error).message}`);
+ } finally {
+ setCenterlineImportBusy(false);
}
- finally { setBusy(false); }
- }, [setOpts]);
+ }, [applyImportedLayer]);
+
+ const onSelectCenterlineLayer = useCallback((layerId: string) => {
+ const layer = centerlineLayers.find((candidate) => candidate.id === layerId);
+ if (layer && centerlineFileName) applyImportedLayer(layer, centerlineFileName);
+ }, [applyImportedLayer, centerlineFileName, centerlineLayers]);
+
+ const onApplyCenterlineCrs = useCallback(() => {
+ if (pendingCenterlineFile && centerlineInputCrs.trim()) {
+ void onImport(pendingCenterlineFile, centerlineInputCrs);
+ }
+ }, [centerlineInputCrs, onImport, pendingCenterlineFile]);
const recordRun = useCallback(async (res: ComputeResponse, remB: Bounds, demB: Bounds | null) => {
setActiveRem({ cog: res.cog_url, dem: res.dem_url || "", bounds: res.bounds });
@@ -500,6 +584,14 @@ export default function App() {
const onCompute = useCallback(async () => {
if (!bboxRef.current) return;
+ const selectedDemRef = demSourceMode === "upload" ? uploadedDem?.ref
+ : demSourceMode === "library" ? libraryDemRef
+ : null;
+ if (opts.engine === "server" && (demSourceMode === "upload" || demSourceMode === "library") && !selectedDemRef) {
+ alert(demSourceMode === "upload" ? "Upload a DEM before computing." : "Select a DEM from the server library.");
+ return;
+ }
+
// Re-click while running → abort
if (computeAbortRef.current) {
computeAbortRef.current.abort();
@@ -516,7 +608,7 @@ export default function App() {
setBusy(true); setRemVisible(true); setResNote(null); setPhase("Finding river"); setPct(10);
try {
let cl = centerline;
- if (opts.mode !== "shapefile") {
+ if (opts.mode === "osm") {
const bbox = bboxRef.current.bbox;
const last = lastCenterlineFetchRef.current;
const bboxW = Math.abs(bbox.east - bbox.west);
@@ -545,7 +637,10 @@ export default function App() {
setCenterInfo({ river_name: r.name, river_length_m: r.length_m });
}
lastCenterlineFetchRef.current = { bbox, qleverMode: opts.qleverMode };
+ setCenterlineFileName(null); setCenterlineLayers([]); setSelectedCenterlineLayerId("");
}
+ } else if (opts.mode === "geojson" && !cl) {
+ throw new Error("Draw or upload a centerline before computing.");
}
setPhase("Sampling river"); setPct(45);
const z = bboxRef.current.zoom;
@@ -588,7 +683,7 @@ export default function App() {
setBusy(true); setRemVisible(true); setResNote(null); setPhase("Finding river"); setPct(8);
try {
let cl = centerline;
- if (opts.mode !== "shapefile") {
+ if (opts.mode === "osm") {
const bbox = bboxRef.current.bbox;
const last = lastCenterlineFetchRef.current;
const bboxW = Math.abs(bbox.east - bbox.west);
@@ -617,7 +712,10 @@ export default function App() {
setCenterInfo({ river_name: r.name, river_length_m: r.length_m });
}
lastCenterlineFetchRef.current = { bbox, qleverMode: opts.qleverMode };
+ setCenterlineFileName(null); setCenterlineLayers([]); setSelectedCenterlineLayerId("");
}
+ } else if (opts.mode === "geojson" && !cl) {
+ throw new Error("Draw or upload a centerline before computing.");
}
const usingShp = opts.mode === "shapefile" && uploadId;
const res = await api.compute(
@@ -626,7 +724,8 @@ export default function App() {
resolution_multiplier: opts.res as 1 | 2 | 4,
centerline_mode: usingShp ? "shapefile" : "geojson",
centerline_geojson: usingShp ? null : cl, upload_id: usingShp ? uploadId : null,
- source_cog_url: demCogUrl.trim() || null,
+ source_cog_url: demSourceMode === "url" ? demCogUrl.trim() || null : null,
+ source_dem_ref: selectedDemRef,
idw_power: opts.power,
},
(ph, p) => { setPhase(ph); setPct(displayPct(ph, p)); },
@@ -646,7 +745,7 @@ export default function App() {
} catch (e) {
if ((e as Error).name !== "AbortError") alert(`Compute failed: ${(e as Error).message}`);
} finally { computeAbortRef.current = null; setBusy(false); setPhase(""); setPct(0); }
- }, [opts.res, opts.mode, opts.osm, opts.engine, opts.samples, opts.power, centerline, centerInfo, uploadId, demCogUrl, setOpts, recordRun, recordClientRun, captureThumb]);
+ }, [opts.res, opts.mode, opts.osm, opts.engine, opts.samples, opts.power, centerline, centerInfo, uploadId, demCogUrl, demSourceMode, uploadedDem, libraryDemRef, setOpts, recordRun, recordClientRun, captureThumb]);
const onLoadCog = useCallback(async (url: string) => {
setBusy(true); setRemVisible(true); setPhase("Reprojecting COG"); setPct(40);
@@ -758,7 +857,7 @@ export default function App() {
const liveAbort = useRef(null);
const onBoundsWithLive = useCallback((bbox: import("@/lib/api").BBox, zoom: number) => {
onBounds(bbox, zoom);
- if (!opts.live || opts.engine !== "client") return;
+ if (!opts.live || opts.engine !== "client" || opts.mode !== "osm") return;
if (liveTimer.current) clearTimeout(liveTimer.current);
liveTimer.current = setTimeout(async () => {
liveAbort.current?.abort();
@@ -779,6 +878,7 @@ export default function App() {
if (!cl) return;
setCenterline(cl);
lastCenterlineFetchRef.current = { bbox, qleverMode: opts.qleverMode };
+ setCenterlineFileName(null); setCenterlineLayers([]); setSelectedCenterlineLayerId("");
// Set result immediately so riverGeojson is non-null before any await boundary.
const wasFirstActivation = !resultRef.current;
const liveResult: import("@/lib/api").ComputeResponse = {
@@ -801,13 +901,13 @@ export default function App() {
setRemToken((n) => n + 1);
} catch (e) { console.warn("[live]", e); }
}, 800);
- }, [onBounds, opts.live, opts.engine, opts.qleverMode, opts.osm, opts.samples, opts.power, opts.interp]);
+ }, [onBounds, opts.live, opts.engine, opts.mode, opts.qleverMode, opts.osm, opts.samples, opts.power, opts.interp]);
// Trigger live immediately when live turns on, or when key options change while live is on.
// onBoundsWithLive is recreated whenever its deps (live, qleverMode, osm, interp, …) change,
// so watching it here fires on all relevant option changes without duplicating the logic.
useEffect(() => {
- if (!opts.live || opts.engine !== "client" || !bboxRef.current) return;
+ if (!opts.live || opts.engine !== "client" || opts.mode !== "osm" || !bboxRef.current) return;
onBoundsWithLive(bboxRef.current.bbox, bboxRef.current.zoom);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [onBoundsWithLive]);
@@ -950,6 +1050,15 @@ export default function App() {
hasDem={!!result?.dem_url}
onSetLayer={onSetLayer}
hasCenterline={!!centerline}
+ centerlineFileName={centerlineFileName}
+ centerlineLayers={centerlineLayers}
+ selectedCenterlineLayerId={selectedCenterlineLayerId}
+ centerlineImportBusy={centerlineImportBusy}
+ centerlineCrsRequired={centerlineCrsRequired}
+ centerlineInputCrs={centerlineInputCrs}
+ setCenterlineInputCrs={setCenterlineInputCrs}
+ onSelectCenterlineLayer={onSelectCenterlineLayer}
+ onApplyCenterlineCrs={onApplyCenterlineCrs}
previewInfo={centerInfo}
runs={runs}
serverRuns={serverRuns}
@@ -965,6 +1074,16 @@ export default function App() {
onLoadCog={onLoadCog}
demCogUrl={demCogUrl}
setDemCogUrl={setDemCogUrl}
+ demCapabilities={demCapabilities}
+ demSourceMode={demSourceMode}
+ setDemSourceMode={setDemSourceMode}
+ uploadedDem={uploadedDem}
+ demUploadBusy={demUploadBusy}
+ demUploadProgress={demUploadProgress}
+ onUploadDem={onUploadDem}
+ demLibrary={demLibrary}
+ libraryDemRef={libraryDemRef}
+ setLibraryDemRef={setLibraryDemRef}
onShare={onShare}
onExportComposite={onExportComposite}
onCopyImage={onCopyImage}
diff --git a/frontend/src/components/SidePanel.tsx b/frontend/src/components/SidePanel.tsx
index b8212ec..f04e28c 100644
--- a/frontend/src/components/SidePanel.tsx
+++ b/frontend/src/components/SidePanel.tsx
@@ -17,7 +17,7 @@ import { rampCss } from "@/lib/colormap";
import { RAMP_NAMES, useUiState } from "@/lib/state";
import { OVERPASS_PRESETS } from "@/lib/osm";
import type { Run } from "@/lib/history";
-import type { ComputeResponse, GeoHit } from "@/lib/api";
+import type { CenterlineImportLayer, ComputeResponse, DemCapabilities, DemItem, GeoHit } from "@/lib/api";
type Opts = {
mode: "osm" | "geojson" | "shapefile";
@@ -71,11 +71,23 @@ function Progress({ active, label, pct }: { active: boolean; label: string; pct:
);
}
+function formatBytes(bytes: number): string {
+ if (bytes < 1024) return `${bytes} B`;
+ const units = ["KB", "MB", "GB", "TB"];
+ let value = bytes / 1024, unit = units[0];
+ for (let i = 1; i < units.length && value >= 1024; i++) { value /= 1024; unit = units[i]; }
+ return `${value >= 10 ? value.toFixed(0) : value.toFixed(1)} ${unit}`;
+}
+
export function SidePanel(p: {
opts: Opts; setOpts: (o: Partial) => void;
busy: boolean; phase: string; pct: number;
resNote: string | null;
- result: ComputeResponse | null; hasCenterline: boolean;
+ result: ComputeResponse | null; hasCenterline: boolean; centerlineFileName: string | null;
+ centerlineLayers: CenterlineImportLayer[]; selectedCenterlineLayerId: string;
+ centerlineImportBusy: boolean; centerlineCrsRequired: boolean; centerlineInputCrs: string;
+ setCenterlineInputCrs: (value: string) => void; onSelectCenterlineLayer: (layerId: string) => void;
+ onApplyCenterlineCrs: () => void;
layer: "rem" | "dem"; hasDem: boolean; onSetLayer: (l: "rem" | "dem") => void;
previewInfo: { river_name: string; river_length_m: number } | null;
runs: Run[]; serverRuns: Run[]; activeRunId: string | null; remVisible: boolean; pickMode: boolean;
@@ -83,6 +95,12 @@ export function SidePanel(p: {
geoHits: GeoHit[];
onPreview: () => void; onCompute: () => void; onUpload: (f: File) => void; onLoadCog: (url: string) => void;
demCogUrl: string; setDemCogUrl: (v: string) => void;
+ demCapabilities: DemCapabilities;
+ demSourceMode: "mapterhorn" | "url" | "upload" | "library";
+ setDemSourceMode: (v: "mapterhorn" | "url" | "upload" | "library") => void;
+ uploadedDem: DemItem | null; demUploadBusy: boolean; demUploadProgress: number;
+ onUploadDem: (f: File) => void;
+ demLibrary: DemItem[]; libraryDemRef: string; setLibraryDemRef: (v: string) => void;
onShare: () => void; onExportComposite: () => void; onCopyImage: () => void;
onExportRaw: () => void; onExportDem: () => void; onExportCenterline: () => void;
onExportClientCog?: (zoom: number) => Promise;
@@ -103,11 +121,15 @@ export function SidePanel(p: {
const { opts, setOpts, busy, result } = p;
const [ui, setUi] = useUiState();
const fileRef = useRef(null);
+ const demFileRef = useRef(null);
const [cogUrl, setCogUrl] = useState("");
const [copied, setCopied] = useState(false);
const [editId, setEditId] = useState(null);
const [editName, setEditName] = useState("");
const [geoQ, setGeoQ] = useState("");
+ const selectedCenterlineLayer = p.centerlineLayers.find(
+ (layer) => layer.id === p.selectedCenterlineLayerId,
+ ) ?? p.centerlineLayers[0] ?? null;
// Min/Max use local text state so partial input like "-" or "-12." is typable;
// committed (parsed + clamped) on blur / Enter.
@@ -144,6 +166,10 @@ export function SidePanel(p: {
const share = () => { p.onShare(); setCopied(true); setTimeout(() => setCopied(false), 3000); };
const flipLayer = () => p.onSetLayer(p.layer === "rem" ? "dem" : "rem");
+ const demReady = p.demSourceMode === "mapterhorn"
+ || (p.demSourceMode === "url" && !!p.demCogUrl.trim())
+ || (p.demSourceMode === "upload" && !!p.uploadedDem && !p.demUploadBusy)
+ || (p.demSourceMode === "library" && !!p.libraryDemRef);
const cardBase =
`pointer-events-auto absolute left-4 top-4 z-50 w-[360px] shadow-2xl backdrop-blur ${
@@ -281,10 +307,52 @@ export function SidePanel(p: {
{opts.mode === "geojson" && (
<>
Click on the map to draw a centerline. Double-click to finish, or upload a file.
- fileRef.current?.click()}>Upload .geojson
+ fileRef.current?.click()}>
+ {p.centerlineImportBusy ? : }
+ {p.centerlineImportBusy ? "Importing centerline…" : "Upload Centerline File"}
+
+
+ Accepted: .geojson (EPSG:4326), .gpkg, .zip Shapefile
+
+ {p.centerlineCrsRequired && (
+
+
CRS metadata is missing. Enter the source CRS.
+
+ p.setCenterlineInputCrs(event.target.value)}
+ onKeyDown={(event) => { if (event.key === "Enter") p.onApplyCenterlineCrs(); }}
+ placeholder="EPSG:6344" aria-label="Centerline input CRS" />
+ Apply CRS
+
+
+ )}
+ {p.centerlineFileName && selectedCenterlineLayer && !p.centerlineCrsRequired && (
+
+
+
+ {p.centerlineFileName}
+
+ {p.centerlineLayers.length > 1 && (
+
+
+ {p.centerlineLayers.map((layer) => (
+ {layer.name}
+ ))}
+
+ )}
+
+ {selectedCenterlineLayer.crs} · {selectedCenterlineLayer.featureCount} line{selectedCenterlineLayer.featureCount === 1 ? "" : "s"} · {selectedCenterlineLayer.vertexCount.toLocaleString()} points · {(selectedCenterlineLayer.lengthM / 1000).toFixed(1)} km
+
+ {selectedCenterlineLayer.warnings.map((warning) => (
+
{warning}
+ ))}
+
+ )}
>
)}
- { (e.target as HTMLInputElement).value = ""; }}
onChange={(e) => e.target.files?.[0] && p.onUpload(e.target.files[0])} />
{p.previewInfo && (
@@ -379,13 +447,62 @@ export function SidePanel(p: {
Experimental — REM built live in the browser (Mapterhorn DEM, {opts.interp === "jfa" ? "nearest-polyline WSE" : opts.interp === "edt" ? "EDT WSE" : "IDW"}), no server compute.
) : (
-
-
Custom DEM COG (optional)
-
p.setDemCogUrl(e.target.value)} placeholder="https://…/dem.tif — overrides Mapterhorn" />
+
+
+ DEM source
+ p.setDemSourceMode(value as typeof p.demSourceMode)}>
+
+
+ Mapterhorn
+ {p.demCapabilities.demSources.url.enabled && COG URL }
+ {p.demCapabilities.demSources.upload.enabled && Upload local DEM }
+ {p.demCapabilities.demSources.library.enabled && {p.demCapabilities.demSources.library.label} }
+
+
+
+
+ {p.demSourceMode === "url" && (
+
p.setDemCogUrl(e.target.value)} placeholder="https://…/dem.tif" />
+ )}
+
+ {p.demSourceMode === "upload" && (
+
+
{ const file = event.target.files?.[0]; if (file) p.onUploadDem(file); event.target.value = ""; }} />
+
demFileRef.current?.click()}>
+ {p.demUploadBusy ? : }
+ {p.uploadedDem ? "Choose another DEM" : "Choose local .tif"}
+
+
+ {p.uploadedDem && !p.demUploadBusy && (
+
+ {p.uploadedDem.name} · {formatBytes(p.uploadedDem.sizeBytes)} · {p.uploadedDem.width}×{p.uploadedDem.height}
+
+ )}
+
+ Streamed to this server; automatically deleted within {p.demCapabilities.demSources.upload.cleanupIntervalMinutes ?? 15} min after {p.demCapabilities.demSources.upload.ttlHours}h. Limit {formatBytes(p.demCapabilities.demSources.upload.maxBytes)}.
+
+
+ )}
+
+ {p.demSourceMode === "library" && (
+
+ {p.demLibrary.length ? (
+
+
+ {p.demLibrary.map((item) => (
+ {item.name} · {formatBytes(item.sizeBytes)}
+ ))}
+
+ ) :
No valid GeoTIFFs are available in the server library.
}
+
+ )}
)}
-
+
{busy ? : }
{busy ? "Abort Computing…" : "Compute REM"}
diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts
index c4237f5..cd0f739 100644
--- a/frontend/src/lib/api.ts
+++ b/frontend/src/lib/api.ts
@@ -10,6 +10,7 @@ export type ComputeRequest = {
centerline_geojson?: GeoJSON.GeoJSON | null;
upload_id?: string | null;
source_cog_url?: string | null;
+ source_dem_ref?: string | null;
idw_power?: number;
interp_pts?: number;
k?: number | null;
@@ -51,6 +52,11 @@ export type ComputeResponse = {
source_max_zoom?: number | null;
dem_zoom?: number | null;
requested_zoom?: number | null;
+ dem_downsampled?: boolean;
+ native_width?: number | null;
+ native_height?: number | null;
+ processed_dem_width?: number | null;
+ processed_dem_height?: number | null;
};
export type JobStatus = {
@@ -61,6 +67,55 @@ export type JobStatus = {
error?: string;
};
+export type DemCapabilities = {
+ demSources: {
+ mapterhorn: { enabled: boolean };
+ url: { enabled: boolean };
+ upload: { enabled: boolean; maxBytes: number; ttlHours: number; cleanupIntervalMinutes?: number };
+ library: { enabled: boolean; label: string };
+ };
+};
+
+export type DemItem = {
+ ref: string;
+ name: string;
+ sizeBytes: number;
+ width: number;
+ height: number;
+ bounds: [number, number, number, number];
+ crs?: string | null;
+};
+
+export type CenterlineImportLayer = {
+ id: string;
+ name: string;
+ crs: string;
+ featureCount: number;
+ vertexCount: number;
+ lengthM: number;
+ warnings: string[];
+ geojson: GeoJSON.FeatureCollection;
+};
+
+export type CenterlineImportResult = {
+ filename: string;
+ layers: CenterlineImportLayer[];
+};
+
+export class CenterlineImportError extends Error {
+ constructor(message: string, public code?: string) {
+ super(message);
+ this.name = "CenterlineImportError";
+ }
+}
+
+export type DemUploadInit = {
+ uploadId: string;
+ ref: string;
+ filename: string;
+ sizeBytes: number;
+};
+
async function post(path: string, body: unknown, signal?: AbortSignal): Promise {
const r = await fetch(`${BASE}${path}`, {
method: "POST",
@@ -86,12 +141,53 @@ export function cogPath(url: string): string | null {
}
export const api = {
+ capabilities: () => get("/capabilities"),
+
+ demLibrary: () => get<{ items: DemItem[] }>("/dem/library"),
+
+ initDemUpload: (file: File) =>
+ post("/dem/uploads", { filename: file.name, size_bytes: file.size }),
+
+ uploadDem: (uploadId: string, file: File, onProgress?: (pct: number) => void) =>
+ new Promise((resolve, reject) => {
+ const xhr = new XMLHttpRequest();
+ xhr.open("PUT", `${BASE}/dem/uploads/${encodeURIComponent(uploadId)}`);
+ xhr.setRequestHeader("Content-Type", "image/tiff");
+ xhr.upload.onprogress = (event) => {
+ if (event.lengthComputable) onProgress?.(Math.round((event.loaded / event.total) * 100));
+ };
+ xhr.onerror = () => reject(new Error("The DEM upload was interrupted"));
+ xhr.onabort = () => reject(new DOMException("Upload aborted", "AbortError"));
+ xhr.onload = () => {
+ let body: any = null;
+ try { body = JSON.parse(xhr.responseText); } catch { /* non-JSON proxy error */ }
+ if (xhr.status >= 200 && xhr.status < 300) resolve(body as DemItem);
+ else reject(new Error(body?.detail ?? xhr.statusText ?? "DEM upload failed"));
+ };
+ xhr.send(file);
+ }),
+
centerlineOsm: (req: Partial & { bbox: BBox; zoom: number }) =>
post<{ geojson: GeoJSON.GeoJSON; river_name: string; river_length_m: number }>(
"/centerline/osm",
{ centerline_mode: "osm", resolution_multiplier: 1, ...req }
),
+ importCenterline: async (file: File, inputCrs?: string): Promise => {
+ const form = new FormData();
+ form.append("file", file);
+ if (inputCrs?.trim()) form.append("input_crs", inputCrs.trim());
+ const response = await fetch(`${BASE}/centerline/import`, { method: "POST", body: form });
+ let body: any = null;
+ try { body = await response.json(); } catch { /* non-JSON proxy error */ }
+ if (!response.ok) {
+ const detail = body?.detail;
+ const message = typeof detail === "string" ? detail : detail?.message;
+ throw new CenterlineImportError(message ?? response.statusText ?? "Centerline import failed", detail?.code);
+ }
+ return body as CenterlineImportResult;
+ },
+
// Job-based compute: start, then poll until done. RiverREM's interpolation %
// is surfaced through onProgress(phase, pct).
compute: async (req: ComputeRequest, onProgress?: (phase: string, pct: number) => void, signal?: AbortSignal) => {