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 %}
+
+
+
+ | 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 documentation
+
+
+
+
+ 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/maps.html b/src/climate_api/templates/map-viewer.html
similarity index 84%
rename from src/climate_api/templates/maps.html
rename to src/climate_api/templates/map-viewer.html
index 53019d86..b3a3d2d4 100644
--- a/src/climate_api/templates/maps.html
+++ b/src/climate_api/templates/map-viewer.html
@@ -171,6 +171,25 @@
.hidden {
display: none !important;
}
+
+ .legend-bar {
+ height: 10px;
+ border-radius: 3px;
+ margin-bottom: 0.3rem;
+ }
+ .legend-labels {
+ display: flex;
+ justify-content: space-between;
+ font-size: 0.75rem;
+ font-family: ui-monospace, "Cascadia Code", monospace;
+ color: #475569;
+ }
+ .legend-units {
+ font-size: 0.72rem;
+ color: #94a3b8;
+ text-align: center;
+ margin-top: 0.2rem;
+ }
@@ -210,6 +229,16 @@ Climate API
- —
+
+
Loading datasets...
@@ -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();
+ });