diff --git a/Makefile b/Makefile
index 7dd4d97e..da4ee879 100644
--- a/Makefile
+++ b/Makefile
@@ -7,7 +7,7 @@ sync: ## Install dependencies with uv
uv sync
run: openapi ## Start the app with uvicorn
- uv run uvicorn climate_api.main:app --reload
+ uv run uvicorn climate_api.main:app --reload --reload-include "*.html" --reload-include "*.yaml" --reload-include "*.yml"
lint: ## Check linting, formatting, and types (no autofix)
uv run ruff check .
diff --git a/src/climate_api/data/datasets/chirps3.yaml b/src/climate_api/data/datasets/chirps3.yaml
index b977bb2d..7fecad80 100644
--- a/src/climate_api/data/datasets/chirps3.yaml
+++ b/src/climate_api/data/datasets/chirps3.yaml
@@ -7,9 +7,13 @@
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
source: CHIRPS v3
source_url: https://www.chc.ucsb.edu/data/chirps3
+ display:
+ colormap: blues
+ range: [0.0, 20.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 91716520..161f6832 100644
--- a/src/climate_api/data/datasets/era5_land.yaml
+++ b/src/climate_api/data/datasets/era5_land.yaml
@@ -17,6 +17,9 @@
resolution: 9 km x 9 km
source: ERA5-Land Reanalysis
source_url: https://earthdatahub.destine.eu/collections/era5/datasets/reanalysis-era5-land
+ display:
+ colormap: rdbu_r
+ range: [233.0, 313.0]
- id: era5land_precipitation_hourly
name: Total precipitation (ERA5-Land)
@@ -38,3 +41,7 @@
resolution: 9 km x 9 km
source: ERA5-Land Reanalysis
source_url: https://earthdatahub.destine.eu/collections/era5/datasets/reanalysis-era5-land
+ display:
+ colormap: blues
+ range: [0.0, 5.0]
+ nodata: 0.0
diff --git a/src/climate_api/data/datasets/worldpop.yaml b/src/climate_api/data/datasets/worldpop.yaml
index ead982b9..b7d9f64c 100644
--- a/src/climate_api/data/datasets/worldpop.yaml
+++ b/src/climate_api/data/datasets/worldpop.yaml
@@ -18,3 +18,7 @@
resolution: 100m x 100m
source: WorldPop Global2
source_url: https://hub.worldpop.org/project/categories?id=3
+ display:
+ colormap: reds
+ range: [0.0, 25.0]
+ nodata: 0.0
diff --git a/src/climate_api/stac/services.py b/src/climate_api/stac/services.py
index 4a0c730e..7f53347a 100644
--- a/src/climate_api/stac/services.py
+++ b/src/climate_api/stac/services.py
@@ -25,6 +25,7 @@
CATALOG_DESCRIPTION = "Published Climate API GeoZarr datasets"
STAC_VERSION = "1.1.0"
DATACUBE_EXTENSION = "https://stac-extensions.github.io/datacube/v2.3.0/schema.json"
+RENDER_EXTENSION = "https://stac-extensions.github.io/render/v2.0.0/schema.json"
ZARR_EXTENSION = "https://stac-extensions.github.io/zarr/v1.1.0/schema.json"
DEFAULT_STAC_LICENSE = "various"
SPATIAL_STEP_DECIMALS = 8
@@ -89,11 +90,16 @@ def build_collection(dataset_id: str, request: Request) -> dict[str, object]:
collection_payload["stac_version"] = STAC_VERSION
collection_payload["description"] = template.description
collection_payload["title"] = template.title
+ renders = _build_renders(artifact, source_dataset)
+ extensions = {DATACUBE_EXTENSION, ZARR_EXTENSION}
+ if renders is not None:
+ collection_payload["renders"] = renders
+ extensions.add(RENDER_EXTENSION)
existing_extensions = collection_payload.get("stac_extensions", [])
if isinstance(existing_extensions, list):
- collection_payload["stac_extensions"] = sorted({*existing_extensions, DATACUBE_EXTENSION, ZARR_EXTENSION})
+ collection_payload["stac_extensions"] = sorted({*existing_extensions, *extensions})
else:
- collection_payload["stac_extensions"] = sorted([DATACUBE_EXTENSION, ZARR_EXTENSION])
+ collection_payload["stac_extensions"] = sorted(extensions)
collection_payload["links"] = template_links
assets = collection_payload.setdefault("assets", {})
zarr_from_xstac = assets.get("zarr", {}) if isinstance(assets, dict) else {}
@@ -427,6 +433,30 @@ def _zarr_open_kwargs(artifact: ArtifactRecord) -> dict[str, bool | None]:
return {"consolidated": _zarr_consolidated_flag(_artifact_store_path(artifact))}
+def _build_renders(artifact: ArtifactRecord, source_dataset: dict[str, Any]) -> dict[str, Any] | None:
+ display = source_dataset.get("display")
+ if not isinstance(display, dict):
+ return None
+ colormap_name = display.get("colormap")
+ value_range = display.get("range")
+ if not isinstance(colormap_name, str) or not isinstance(value_range, list) or len(value_range) != 2:
+ return None
+ render: dict[str, Any] = {
+ "title": artifact.dataset_name,
+ "assets": ["zarr"],
+ "rescale": [[float(value_range[0]), float(value_range[1])]],
+ "colormap_name": colormap_name,
+ "climate_api:variable": artifact.variable,
+ }
+ nodata = display.get("nodata")
+ if nodata is not None:
+ render["nodata"] = float(nodata)
+ units = source_dataset.get("convert_units") or source_dataset.get("units")
+ if isinstance(units, str):
+ render["climate_api:units"] = units
+ return {"default": render}
+
+
def _zarr_consolidated_flag(artifact_path: str) -> bool | None:
if "://" in artifact_path:
return None
diff --git a/src/climate_api/system/routes.py b/src/climate_api/system/routes.py
index e52a5911..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, root_json, wants_json
+from .templates import ROOT_RESPONSES, app_version, render_landing, render_manage, render_maps, root_json, wants_json
router = APIRouter()
@@ -21,6 +23,96 @@ def read_index(request: Request) -> Response:
return HTMLResponse(render_landing(app_version, base))
+@router.get("/map", response_class=HTMLResponse, include_in_schema=False)
+def maps(request: Request) -> HTMLResponse:
+ """Return the interactive map viewer."""
+ base = str(request.base_url).rstrip("/")
+ 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 6caa992a..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
@@ -94,23 +96,60 @@ def wants_json(request: Request) -> bool:
return json_q >= 0 and (html_q < 0 or json_q >= html_q)
-def render_landing(version: str, base: str) -> str:
- """Render the root landing page with live instance status."""
+def render_maps(base: str) -> str:
+ """Render the map viewer page."""
+ return get_template("map-viewer.html").render(base=base)
+
+
+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")
+ return None
+
+
+def _load_templates() -> list[dict[str, Any]]:
+ try:
+ return registry_datasets.list_datasets()
except Exception:
- _log.exception("Unexpected error loading extent for landing page")
- extent = None
+ _log.exception("Unexpected error loading dataset templates")
+ return []
+
+
+def _load_datasets() -> list[Any]:
try:
- datasets = list_datasets().items
+ return list_datasets().items
except Exception:
- _log.exception("Unexpected error loading datasets for landing page")
- datasets = []
+ _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 49cd5999..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 %}
+
+
+
+ | Name |
+ Variable |
+ Period |
+ Source |
+
+
+
+ {% for t in templates %}
+
+ | {{ t.name }} |
+ {{ t.variable }} |
+ {{ t.period_type }} |
+
+ {% if t.source_url %}
+ {{ t.source or t.id }}
+ {% else %}
+ {{ t.source or '—' }}
+ {% endif %}
+ |
+
+ {% endfor %}
+
+
+ {% else %}
+
No dataset templates found.
+ {% endif %}
+
+
Explore
+ -
+ Manage
+ Ingest and sync datasets without using the API directly
+
-
API documentationExplore
>Interactive Swagger UI for all endpoints
+ -
+ Map viewer
+ Browse published datasets on an interactive map
+
-
STAC Catalog
+
+
+
+
+ Manage — DHIS2 Climate API
+
+
+
+
+
+
+ {% if message %}
+
{{ message }}
+ {% endif %}
+ {% if error %}
+ {{ error }}
+ {% endif %}
+
+
+
+
Ingest dataset
+ {% if not extent %}
+
+ No extent configured. Set extent: in
+ CLIMATE_API_CONFIG before ingesting data.
+
+ {% elif not templates %}
+
No dataset templates found.
+ {% else %}
+
+ {% endif %}
+
+
+
+
+
Ingested datasets {{ datasets | length }}
+ {% if datasets %}
+
+
+
+ | Name |
+ Period |
+ Temporal coverage |
+ Status |
+ |
+
+
+
+ {% for ds in datasets %}
+
+ |
+ {{ ds.dataset_name }}
+ |
+ {{ ds.period_type }} |
+ {{ ds.extent.temporal.start }} – {{ ds.extent.temporal.end }} |
+
+ {% if ds.publication.status == "published" %}
+ published
+ {% else %}
+ unpublished
+ {% endif %}
+ |
+
+
+ |
+
+ {% endfor %}
+
+
+ {% else %}
+
+ No datasets ingested yet. Use the form above to ingest your first dataset.
+
+ {% endif %}
+
+
+
+
+
+
diff --git a/src/climate_api/templates/map-viewer.html b/src/climate_api/templates/map-viewer.html
new file mode 100644
index 00000000..5c8ce7df
--- /dev/null
+++ b/src/climate_api/templates/map-viewer.html
@@ -0,0 +1,516 @@
+
+
+
+
+
+ Map viewer — DHIS2 Climate API
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ - Source
+ - —
+ - Units
+ - —
+
+
+
+
+
Loading datasets...
+
+
+
+
+
+
+