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/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) 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.

- + +

+ 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" /> + +
+
+ )} + {p.centerlineFileName && selectedCenterlineLayer && !p.centerlineCrsRequired && ( +
+

+ + {p.centerlineFileName} +

+ {p.centerlineLayers.length > 1 && ( + + )} +

+ {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.

) : ( -
- - p.setDemCogUrl(e.target.value)} placeholder="https://…/dem.tif — overrides Mapterhorn" /> +
+
+ + +
+ + {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 = ""; }} /> + + + {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 ? ( + + ) :

No valid GeoTIFFs are available in the server library.

} +
+ )}
)} - 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) => {