diff --git a/src/climate_api/data/datasets/chirps3.yaml b/src/climate_api/data/datasets/chirps3.yaml index 0ff48b3c..7fecad80 100644 --- a/src/climate_api/data/datasets/chirps3.yaml +++ b/src/climate_api/data/datasets/chirps3.yaml @@ -7,7 +7,7 @@ sync_execution: append sync_availability: latest_available_function: climate_api.providers.availability.chirps3_daily_latest_available - ingestion: + ingestion: function: dhis2eo.data.chc.chirps3.daily.download units: mm resolution: 5 km x 5 km @@ -16,4 +16,4 @@ display: colormap: blues range: [0.0, 20.0] - nodata: 0.0 + nodata: -9999.0 diff --git a/src/climate_api/data/datasets/era5_land.yaml b/src/climate_api/data/datasets/era5_land.yaml index adc354f9..161f6832 100644 --- a/src/climate_api/data/datasets/era5_land.yaml +++ b/src/climate_api/data/datasets/era5_land.yaml @@ -19,7 +19,7 @@ source_url: https://earthdatahub.destine.eu/collections/era5/datasets/reanalysis-era5-land display: colormap: rdbu_r - range: [15.0, 40.0] + range: [233.0, 313.0] - id: era5land_precipitation_hourly name: Total precipitation (ERA5-Land) diff --git a/src/climate_api/data/datasets/worldpop.yaml b/src/climate_api/data/datasets/worldpop.yaml index 22d85960..b7d9f64c 100644 --- a/src/climate_api/data/datasets/worldpop.yaml +++ b/src/climate_api/data/datasets/worldpop.yaml @@ -19,6 +19,6 @@ source: WorldPop Global2 source_url: https://hub.worldpop.org/project/categories?id=3 display: - colormap: viridis - range: [0.0, 1000.0] + colormap: reds + range: [0.0, 25.0] nodata: 0.0 diff --git a/src/climate_api/system/routes.py b/src/climate_api/system/routes.py index 05af11b7..4b9e7fe8 100644 --- a/src/climate_api/system/routes.py +++ b/src/climate_api/system/routes.py @@ -1,13 +1,15 @@ """Root API endpoints.""" import sys +import urllib.parse from importlib.metadata import version as _pkg_version from fastapi import APIRouter, Request from fastapi.responses import HTMLResponse, JSONResponse, Response +from starlette.responses import RedirectResponse from .schemas import AppInfo, HealthStatus, Status -from .templates import ROOT_RESPONSES, app_version, render_landing, render_maps, root_json, wants_json +from .templates import ROOT_RESPONSES, app_version, render_landing, render_manage, render_maps, root_json, wants_json router = APIRouter() @@ -28,6 +30,89 @@ def maps(request: Request) -> HTMLResponse: return HTMLResponse(render_maps(base)) +@router.get("/manage", response_class=HTMLResponse, include_in_schema=False) +def manage( + request: Request, + message: str | None = None, + error: str | None = None, +) -> HTMLResponse: + """Return the management interface for ingestion and sync operations.""" + base = str(request.base_url).rstrip("/") + return HTMLResponse(render_manage(app_version, base, message=message, error=error)) + + +@router.post("/manage/ingest", include_in_schema=False) +async def manage_ingest(request: Request) -> RedirectResponse: + """Handle ingest form submission and redirect to the management page.""" + from fastapi import HTTPException + + from climate_api.data_registry.services.datasets import get_dataset + from climate_api.extents.services import get_extent + from climate_api.ingestions.services import create_artifact + + base = str(request.base_url).rstrip("/") + try: + form = await request.form() + dataset_id = str(form.get("dataset_id", "")) + start = str(form.get("start", "")) + end = str(form.get("end", "")) or None + publish = "publish" in form + overwrite = "overwrite" in form + + template = get_dataset(dataset_id) + if template is None: + msg = urllib.parse.quote(f"Dataset template '{dataset_id}' not found") + return RedirectResponse(f"{base}/manage?error={msg}", status_code=303) + + extent = get_extent() + resolved_bbox = list(extent["bbox"]) if extent else None + extent_id = extent["id"] if extent else None + country_code = extent.get("country_code") if extent else None + + create_artifact( + dataset=template, + start=start, + end=end, + extent_id=extent_id, + bbox=resolved_bbox, + country_code=country_code, + overwrite=overwrite, + prefer_zarr=True, + publish=publish, + ) + name = urllib.parse.quote(template.get("name", dataset_id)) + return RedirectResponse(f"{base}/manage?message=Ingested+{name}", status_code=303) + except HTTPException as exc: + msg = urllib.parse.quote(str(exc.detail)) + return RedirectResponse(f"{base}/manage?error={msg}", status_code=303) + except Exception as exc: + msg = urllib.parse.quote(str(exc)) + return RedirectResponse(f"{base}/manage?error={msg}", status_code=303) + + +@router.post("/manage/sync", include_in_schema=False) +async def manage_sync(request: Request) -> RedirectResponse: + """Handle sync form submission and redirect to the management page.""" + from fastapi import HTTPException + + from climate_api.ingestions.services import sync_dataset + + base = str(request.base_url).rstrip("/") + try: + form = await request.form() + dataset_id = str(form.get("dataset_id", "")) + publish = "publish" in form + + sync_dataset(dataset_id=dataset_id, end=None, prefer_zarr=True, publish=publish) + return RedirectResponse(f"{base}/manage?message=Sync+completed", status_code=303) + except HTTPException as exc: + msg = urllib.parse.quote(str(exc.detail)) + return RedirectResponse(f"{base}/manage?error={msg}", status_code=303) + except Exception as exc: + msg = urllib.parse.quote(str(exc)) + return RedirectResponse(f"{base}/manage?error={msg}", status_code=303) + + @router.get("/health") def health() -> HealthStatus: """Return health status for container health checks.""" diff --git a/src/climate_api/system/templates.py b/src/climate_api/system/templates.py index 2aba0070..6cfeca77 100644 --- a/src/climate_api/system/templates.py +++ b/src/climate_api/system/templates.py @@ -2,6 +2,7 @@ import importlib.resources import logging +from datetime import date from importlib.metadata import PackageNotFoundError from importlib.metadata import version as _pkg_version from typing import Any @@ -9,6 +10,7 @@ import jinja2 from fastapi import Request +from climate_api.data_registry.services import datasets as registry_datasets from climate_api.extents.services import get_extent from climate_api.ingestions.services import list_datasets @@ -96,26 +98,58 @@ def wants_json(request: Request) -> bool: def render_maps(base: str) -> str: """Render the map viewer page.""" - return get_template("maps.html").render(base=base) + return get_template("map-viewer.html").render(base=base) -def render_landing(version: str, base: str) -> str: - """Render the root landing page with live instance status.""" +def _load_extent() -> dict[str, Any] | None: try: - extent: dict[str, Any] | None = get_extent() + return get_extent() except ValueError: - extent = None + return None except Exception: - _log.exception("Unexpected error loading extent for landing page") - extent = None + _log.exception("Unexpected error loading extent") + return None + + +def _load_templates() -> list[dict[str, Any]]: try: - datasets = list_datasets().items + return registry_datasets.list_datasets() except Exception: - _log.exception("Unexpected error loading datasets for landing page") - datasets = [] + _log.exception("Unexpected error loading dataset templates") + return [] + + +def _load_datasets() -> list[Any]: + try: + return list_datasets().items + except Exception: + _log.exception("Unexpected error loading datasets") + return [] + + +def render_landing(version: str, base: str) -> str: + """Render the root landing page with live instance status.""" return get_template("landing_page.html").render( version=version, base=base, - extent=extent, - datasets=datasets, + extent=_load_extent(), + datasets=_load_datasets(), + templates=_load_templates(), + ) + + +def render_manage(version: str, base: str, message: str | None = None, error: str | None = None) -> str: + """Render the management page.""" + today = date.today().isoformat() + year_ago = date.today().replace(year=date.today().year - 1).isoformat() + return get_template("manage.html").render( + version=version, + base=base, + extent=_load_extent(), + templates=_load_templates(), + datasets=_load_datasets(), + today=today, + year_ago=year_ago, + message=message, + error=error, ) diff --git a/src/climate_api/templates/landing_page.html b/src/climate_api/templates/landing_page.html index 3e4ce5a3..6bd7703b 100644 --- a/src/climate_api/templates/landing_page.html +++ b/src/climate_api/templates/landing_page.html @@ -264,10 +264,53 @@

Datasets {{ datasets | length }}

{% endif %} + +
+

Available dataset templates {{ templates | length }}

+ {% if templates %} + + + + + + + + + + + {% for t in templates %} + + + + + + + {% endfor %} + +
NameVariablePeriodSource
{{ t.name }}{{ t.variable }}{{ t.period_type }} + {% if t.source_url %} + {{ t.source or t.id }} + {% else %} + {{ t.source or '—' }} + {% endif %} +
+ {% else %} +

No dataset templates found.

+ {% endif %} +
+

Explore

@@ -281,26 +310,7 @@

Climate API

const map = new maplibregl.Map({ container: "map", - style: { - version: 8, - sources: { - osm: { - type: "raster", - tiles: ["https://tile.openstreetmap.org/{z}/{x}/{y}.png"], - tileSize: 256, - attribution: - '© OpenStreetMap', - }, - }, - layers: [ - { - id: "osm", - type: "raster", - source: "osm", - paint: { "raster-opacity": 0.5 }, - }, - ], - }, + style: "https://tiles.openfreemap.org/styles/positron", center: [20, 5], zoom: 2, fitBoundsOptions: { padding: 40 }, @@ -319,8 +329,25 @@

Climate API

const datasetMeta = document.getElementById("dataset-meta"); const metaSource = document.getElementById("meta-source"); const metaUnits = document.getElementById("meta-units"); + const legendEl = document.getElementById("legend"); + const legendBar = document.getElementById("legend-bar"); + const legendMin = document.getElementById("legend-min"); + const legendMax = document.getElementById("legend-max"); + const legendUnits = document.getElementById("legend-units"); const statusEl = document.getElementById("status"); + function updateLegend(cm, clim, units) { + const stops = Array.from( + { length: 32 }, + (_, i) => cm[Math.round((i * (cm.length - 1)) / 31)] + ); + legendBar.style.background = `linear-gradient(to right, ${stops.join(", ")})`; + legendMin.textContent = clim[0]; + legendMax.textContent = clim[1]; + legendUnits.textContent = units ? `(${units})` : ""; + legendEl.classList.remove("hidden"); + } + function setStatus(msg, isError = false) { statusEl.textContent = msg; statusEl.className = isError ? "status error" : "status"; @@ -372,6 +399,7 @@

Climate API

} timeSection.classList.add("hidden"); datasetMeta.classList.add("hidden"); + legendEl.classList.add("hidden"); let collection; try { @@ -392,6 +420,7 @@

Climate API

const clim = renders.rescale?.[0] ?? [0, 100]; const colormapName = renders.colormap_name ?? "viridis"; + const fillValue = renders.nodata ?? null; const variable = renders["climate_api:variable"] ?? Object.keys(collection["cube:variables"] ?? {})[0] ?? @@ -401,8 +430,9 @@

Climate API

timeDimKey = getTimeDimKey(dimensions); timeSteps = buildTimeSteps(dimensions); + let cm; try { - const cm = buildColormap(colormapName); + cm = buildColormap(colormapName); const zarrVersion = zarr["zarr:zarr_format"] ?? null; const selector = timeSteps.length > 0 ? { [timeDimKey]: 0 } : {}; @@ -412,26 +442,22 @@

Climate API

variable, clim, colormap: cm, + opacity: 1, selector, ...(zarrVersion !== null && { zarrVersion }), + ...(fillValue !== null && { fillValue }), }); map.addLayer(activeLayer); + for (const layer of map.getStyle().layers) { + if ((layer.type === "line" && layer.id.startsWith("boundary")) || layer.type === "symbol") { + map.moveLayer(layer.id); + } + } } catch (err) { setStatus(`Failed to create layer: ${err.message}`, true); return; } - // Zoom to dataset spatial extent. - const bbox = collection.extent?.spatial?.bbox?.[0]; - if (bbox) { - map.fitBounds( - [ - [bbox[0], bbox[1]], - [bbox[2], bbox[3]], - ], - { padding: 60, maxZoom: 8 } - ); - } // Time slider. if (timeSteps.length > 1) { @@ -450,16 +476,16 @@

Climate API

k !== variable && k !== collection.id ); - metaSource.textContent = - renders["climate_api:units"] - ? source ?? "—" - : source ?? "—"; - metaUnits.textContent = + const units = renders["climate_api:units"] ?? collection["cube:variables"]?.[variable]?.unit ?? - "—"; + ""; + metaSource.textContent = source ?? "—"; + metaUnits.textContent = units || "—"; datasetMeta.classList.remove("hidden"); + updateLegend(cm, clim, units); + setStatus(""); } @@ -474,7 +500,9 @@

Climate API

activeLayer?.setSelector({ [timeDimKey]: i }); }); - map.on("load", loadCatalog); + map.on("load", () => { + loadCatalog(); + });